Cloud Systems — Azure IaC Deployment
Jacob Rickey  ·  jr521816@ohio.edu  ·  ITS 5900-120  ·  Spring 2026  ·  jrickey.cc  ·  GitHub
Operational
UTC —:—:—
Project Overview

Two VMs on Azure, provisioned with OpenTofu and configured with Ansible. tofu apply provisions the network, VMs, Key Vault, and monitoring. ansible-playbook installs and hardens both machines. Nothing is done manually.

Ask Claude

Claude runs on this server with access to live system tools. Ask about the architecture, the security design, or what the server is doing right now.

300 characters remaining
Claude Activity

Loading...

Infrastructure
Host
Domainjrickey.cc
RegionAzure South Central US
ProtocolHTTP/2 · TLS 1.3 only
CertificateLet's Encrypt (auto-renew)
Virtual Machines
App VMStandard_B2pts_v2 · ARM64
DB VMStandard_B2pts_v2 · ARM64
OSUbuntu 24.04 LTS (arm64)
AuthSSH key only (Ed25519), no passwords
SSH KExsntrup761x25519 (PQ hybrid) + classical fallback
Network
VNet10.0.0.0/16
App subnet10.0.1.0/24 (public IP)
DB subnet10.0.2.0/24 (no public IP)
DB port 3306App subnet only (NSG rule)
DB SSHApp subnet only (bastion pattern)
Automation Stack
ProvisioningOpenTofu
ConfigurationAnsible
SecretsAzure Key Vault (auto-generated, runtime fetch)
OpenTofu stateAzure Blob Storage (shared, locked)
MonitoringAzure Monitor + email alerts
DNSAzure DNS · DNSSEC signed
AI interfaceMCP over Streamable HTTP
Security Hardening
  • Post-quantum SSH. Both VMs use sntrup761x25519-sha512 as the preferred key exchange — a hybrid of the NIST-selected post-quantum lattice algorithm (NTRU) and X25519. Host and client keys are Ed25519 only. MACs are ETM-mode only; ciphers are ChaCha20-Poly1305 and AES-GCM.
  • SSH key-only on both VMs. Password auth is disabled. The DB has no public IP; reaching it requires tunneling through the app VM.
  • fail2ban on both VMs. 5 failed SSH attempts in 10 minutes triggers a 1-hour IP ban.
  • Unattended-upgrades applies security patches automatically from Ubuntu's security repo.
  • MariaDB hardened at deploy time via Ansible: anonymous users removed, test DB dropped, remote root disabled. Equivalent to mysql_secure_installation, fully scripted.
  • MariaDB bound to the DB subnet interface only. It will not accept connections on any other NIC, even within the VNet.
  • NSG: port 3306 open from 10.0.1.0/24 only. No public IP on the DB VM. Two independent enforcement layers, no path from the internet to the database.
  • Zero-trust secrets. OpenTofu generates the DB password via random_password, stores it in Azure Key Vault. Ansible fetches it at runtime. Nothing is typed or stored in code.
  • Azure Monitor alerts on CPU above 85% sustained for 5 minutes.
The NSG drops anything not from the app subnet. MariaDB's bind-address means it isn't listening on other interfaces anyway. Either one is sufficient; both eliminate any single point of misconfiguration.
How It Evolved
1
Iteration 1
Scaffolding: single VM, HTTP only
One VM, one subnet, no TLS. OpenTofu provisions the VM and public IP; Ansible installs nginx and serves a static page. Goal: get the full IaC pipeline working end-to-end. Manual SSH was the baseline to beat.
TerraformAnsiblenginx
2
Iteration 2
Two-tier network: app and DB on separate subnets
Split into two VMs. The DB gets no public IP; only the app subnet can reach port 3306. Ansible now runs two playbooks, one per host group. OpenTofu's local_file resource writes the live IP into Ansible's inventory on every apply.
Two-tierNSGMariaDBInventory automation
3
Iteration 3
TLS, secrets, remote state
certbot needs port 80 for the ACME challenge, but the final nginx config references a cert that doesn't exist yet. Fix: deploy an HTTP-only config first (when: not cert_stat.stat.exists), run certbot, swap in the SSL config. State moved to Azure Blob Storage. nip.io for a real domain without buying one.
Let's EncryptAzure Blobnip.io
4
Iteration 4
Hardening + ARM64
fail2ban, unattended-upgrades, and MariaDB hardening via Ansible's mysql_user / mysql_db modules. Switched to Standard_B2pts_v2 (Ampere ARM64): same tier, cheaper, fully supported on Ubuntu 24.04. All tasks are idempotent.
fail2banUnattended-upgradesARM64MariaDB hardening
5
Iteration 5
Zero-trust secrets via Azure Key Vault
OpenTofu generates the DB password via random_password and stores it in Key Vault as part of tofu apply. Ansible fetches it at runtime with az keyvault secret show. Nothing is typed, nothing is stored locally.
Azure Key VaultRBACPasswordless
6
Iteration 6
MCP servers + Azure Monitor
Two Model Context Protocol servers: mcp-infra on the app VM for system metrics, mcp-db on the DB VM for database queries. Both run as systemd services via FastMCP + uvicorn, proxied through nginx. The Ask Claude box on this page uses the same tools. Azure Monitor alerts on CPU above 85% for 5 minutes.
MCP / Streamable HTTPFastMCPsystemdAzure MonitorAction Groups
7
Iteration 7
Self-healing: sentinel monitoring + automated remediation
A systemd timer runs a sentinel script every 5 minutes on the app VM. It checks that nginx, ask-app, and mcp-infra are active, verifies the nginx config checksum against the last known-good state, and auto-restarts any failed service. All events are written to /var/log/sentinel.log. A separate repair_web.yml playbook restores the full stack from Ansible templates when deeper remediation is needed. The Cloud Shell MCP hub exposes get_sentinel_log and apply_remediation tools so Claude can monitor and remediate without logging into any VM.
systemd timerconfig integrityauto-restartMCP remediation
8
Iteration 8
Post-quantum hardening: PQ SSH, TLS 1.3-only, DNSSEC
Hardened the transport stack as far as current tooling allows. SSH now negotiates sntrup761x25519-sha512 (NTRU + X25519 hybrid) as the preferred key exchange on both VMs — resistant to harvest-now/decrypt-later attacks. Host and client keys are Ed25519 only. nginx is restricted to TLS 1.3 only (dropping 1.2), with a one-year HSTS preload header. DNS moved from nip.io to Azure DNS with DNSSEC signing — responses are cryptographically authenticated, preventing cache poisoning and spoofing. The CA/PKI layer remains classical (Let's Encrypt doesn't issue PQ certificates yet), but all other layers are hardened.
Post-quantum SSHsntrup761x25519TLS 1.3DNSSECAzure DNSHSTS preload
MCP Interface

MCP (Model Context Protocol) lets AI assistants call structured tools on a remote server. This deployment runs two:

  • mcp-infra (this VM) — read-only tools: live system metrics and nginx stats.
  • mcp-db (DB VM): database query tools. Private, only reachable through the app VM.
Where does the AI live? Claude runs on Anthropic's servers — not on these VMs. When you ask a question, the app VM sends a request to the Claude API with a description of the available tools. Claude decides which tools to call, the app VM runs them locally and returns the results, and Claude writes the final answer. The AI never has direct access to the servers; it only sees what the tools explicitly return.
Is it safe? The tools are read-only functions defined in code — system_info, nginx_stats, sentinel_log. Claude can ask for those outputs, but it cannot run arbitrary commands, write files, or reach the DB VM directly. The same XSS sanitization that protects this page applies to Claude's responses too — all output is HTML-escaped before rendering.
MCP / Streamable HTTP FastMCP read-only tools Anthropic API systemd
Your Connection
Browser
OS
Language
Screen
CPU threads
Cookies
Page load
User agent
Available MCP Tools
system_infoUptime, load averages (1m/5m/15m), memory, and disk usage
nginx_statsActive connections and request counts from nginx stub_status
write_activityLogs a summary of each Claude session to the Claude Activity panel
Connect Claude Code by adding this to ~/.claude/mcp.json:
Connect with Claude Code
{
  "mcpServers": {
    "cloud-infra": {
      "type": "http",
      "url":  "https://jrickey.cc/mcp/"
    }
  }
}
Self-Healing Architecture

A sentinel script runs every 5 minutes via systemd timer on the app VM. It checks service health, verifies the nginx config checksum against the post-deploy baseline, and auto-restarts failed services. If deeper repair is needed, repair_web.yml restores the full stack from the Ansible templates stored in source control. The Cloud Shell MCP hub gives Claude direct access to the sentinel log and the repair playbook without any human logging in.

Sentinel checks (every 5 min)
  • nginx, ask-app, mcp-infra active via systemctl
  • nginx config checksum matches post-deploy baseline
  • nginx_status endpoint responds on localhost
  • Auto-restarts failed services, logs all events
MCP hub tools (Cloud Shell)
  • get_sentinel_log — read recent health events
  • check_health — live service status
  • get_nginx_errors — nginx error log
  • apply_remediation — run repair_web.yml
How config integrity works: After every deploy, Ansible runs sha256sum on the nginx config and saves the result — a 64-character fingerprint unique to that exact file. Every 5 minutes the sentinel recomputes the fingerprint and compares it to the saved one. If even a single character changed, the hashes won't match and sentinel raises an alert. SHA-256 is a one-way cryptographic hash: you can't reverse it or fake a match without controlling the file. This is the same mechanism used to verify software downloads.
The repair playbook restores nginx config from the Ansible template, fixes SSL cert permissions, restores webroot ownership, and restarts all services. It stores a fresh config checksum so the sentinel knows the new baseline. Cloud Shell is the only access point; no one logs into the VM to fix things.
systemd timer config integrity auto-restart Ansible remediation MCP hub ZTA
Server Metrics
Load (1m)
Memory Used
Disk Used
Uptime
Load avg (1m / 5m / 15m)
Memory
Disk
Nginx connections
Last updated by Claude
Deploy
1. Provision
# Creates VNet, subnets, NSGs, VMs, Key Vault, Monitor alerts,
# and writes inventory.ini + group_vars with the live public IP.
tofu apply -auto-approve
2. Configure
# Installs and hardens both VMs: nginx, TLS, fail2ban,
# unattended-upgrades, MariaDB, MCP servers. All tasks are idempotent.
# DB password fetched from Azure Key Vault at runtime (az keyvault secret show).
ansible-playbook setup_app.yml setup_db.yml -i inventory.ini
OpenTofu
NSG: DB only reachable from app subnet
resource "azurerm_network_security_group" "db_nsg" {
  # No public IP on DB VM + NSG = two independent
  # enforcement layers blocking internet access.

  security_rule {
    name                  = "AllowMySQL"
    priority              = 100
    direction             = "Inbound"
    access                = "Allow"
    destination_port      = "3306"
    # app subnet only; nothing else can reach MariaDB
    source_address_prefix = "10.0.1.0/24"
  }
}
Key Vault: auto-generated DB password
resource "azurerm_key_vault" "kv" {
  name      = "cloud-v3-kv"
  tenant_id = data.azurerm_client_config.current.tenant_id
  sku_name  = "standard"
  # DB password auto-generated by random_password resource,
  # stored here by tofu apply; never typed or on disk.
}

# Terraform writes the live IP into Ansible's inventory
# on every apply; no manual IP hunting after redeploy.
resource "local_file" "ansible_vars" {
  content  = "app_domain: \"${azurerm_public_ip.app_pip.ip_address}.nip.io\""
  filename = "group_vars/all/terraform_outputs.yml"
}
Ansible
Certbot: HTTP-first fix
# Chicken-and-egg: certbot needs port 80 open to
# validate the domain, but the final nginx config
# references the cert that doesn't exist yet.
- name: Deploy HTTP-only nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  when: not cert_stat.stat.exists  # skip if cert already exists

- name: Obtain SSL certificate
  command: >
    certbot certonly --webroot -w /var/www/html
    -d {{ app_domain }} --non-interactive
  args:
    creates: /etc/letsencrypt/live/{{ app_domain }}/fullchain.pem

- name: Deploy HTTPS nginx config  # now the cert exists
  template:
    src: nginx_ssl.conf.j2
    dest: /etc/nginx/nginx.conf
MariaDB: scripted hardening
# Equivalent to running mysql_secure_installation,
# but fully automated and idempotent.
- name: Remove anonymous users
  mysql_user:
    name: ''
    host_all: yes
    state: absent

- name: Disallow remote root login
  mysql_user:
    name: root
    host: '%'
    state: absent

- name: Bind MariaDB to internal subnet only
  copy:
    dest: /etc/mysql/mariadb.conf.d/99-bind.cnf
    content: |
      [mysqld]
      bind-address = {{ db_bind_address }}
External Security Scan

nmap scanned from Azure Cloud Shell — same view an attacker on the internet would have. Only ports 80 and 443 should be reachable. Port 3306 (MariaDB), 8000/8002 (MCP servers), and 22 (SSH) are blocked by the NSG and should not appear as open.

nmap output
Loading scan results...
nmap external perspective NSG validation SSL audit