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

One VM on Azure, provisioned with OpenTofu and configured with Ansible. tofu apply provisions the network, VM, Key Vault, and monitoring. ansible-playbook installs and hardens the server. Nothing is done manually.

Ask Claude

Ask Claude anything about this project's architecture, security design, or how it was built.

300 characters remaining
Infrastructure
Host
Domainjrickey.cc
RegionAzure South Central US
ProtocolHTTP/2 · TLS 1.3 only
CertificateLet's Encrypt (auto-renew)
Virtual Machine
App VMStandard_B2pts_v2 · ARM64
OSUbuntu LTS (arm64)
AuthSSH key only (Ed25519), no passwords
Network
App VMPublic IP, internet-facing
Automation Stack
ProvisioningOpenTofu
ConfigurationAnsible
SecretsAzure Key Vault (auto-generated, runtime fetch)
OpenTofu stateAzure Blob Storage (shared, locked)
MonitoringAzure Monitor + email alerts
DNSCloudflare DNS · Azure DNS zone provisioned via OpenTofu
AI interfaceMCP over Streamable HTTP
Security Hardening
  • SSH hardened: key-only auth (Ed25519), no passwords, root login disabled, max 3 auth attempts.
  • fail2ban: repeated failed SSH attempts trigger automatic IP bans.
  • Unattended-upgrades applies security patches automatically.
  • Zero-trust secrets: API keys auto-generated by OpenTofu, stored in Azure Key Vault, fetched at runtime via managed identity. Never typed or stored in code.
  • Azure Monitor alerts on CPU above 85% for 5 minutes.
How It Evolved
1
Iteration 1
Scaffolding: single VM, HTTP only
One VM, one subnet, no TLS. Goal: get the full IaC pipeline working end-to-end.
TerraformAnsiblenginx
2
Iteration 2
Two-tier network: app and DB on separate subnets
Added a DB VM with no public IP. Only the app subnet could reach it, enforced at both the NSG and network layer. (DB later removed — not needed for this project's scope.)
Two-tierNSGInventory automation
3
Iteration 3
TLS, secrets, remote state
Added HTTPS via Let's Encrypt, Terraform state in Azure Blob Storage, and nip.io for a free domain.
Let's EncryptAzure Blobnip.io
4
Iteration 4
Hardening + ARM64
Added fail2ban, auto security updates, and switched to ARM64 VMs (cheaper, same tier).
fail2banUnattended-upgradesARM64
5
Iteration 5
Zero-trust secrets via Azure Key Vault
OpenTofu generates API keys and stores them in Azure Key Vault. The VM fetches secrets at boot via managed identity. Nothing is ever typed or stored in code.
Azure Key VaultRBACPasswordless
6
Iteration 6
MCP servers + Azure Monitor
An MCP server lets Claude query live system metrics. The Ask Claude box on this page uses it. Added Azure Monitor CPU alerts.
MCP / Streamable HTTPFastMCPsystemdAzure MonitorAction Groups
7
Iteration 7
Self-healing: sentinel monitoring + automated remediation
A script runs every 5 minutes, checks that all services are up and the nginx config hasn't changed, and restarts anything that's down.
systemd timerconfig integrityauto-restartMCP remediation
8
Iteration 8
Transport hardening: TLS 1.3-only, HSTS, real domain
Moved to a real domain, TLS 1.3 only, one-year HSTS, and tighter SSH config on both VMs.
TLS 1.3HSTS preloadCloudflare DNSAzure DNSSSH hardening
MCP Interface

MCP (Model Context Protocol) is an open standard that lets AI models call tools on external servers. This deployment runs one MCP server (mcp-infra) on the app VM, accessible to the owner via Claude Code.

Claude runs on Anthropic's servers, not on these VMs. The VM calls the Claude API, Claude picks which tools to run, and only sees what they return.
MCP / Streamable HTTP FastMCP read-only tools Anthropic API systemd
Your Connection
Browser
OS
Language
Screen
CPU threads
Cookies
Page load
User agent
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 any failed service. All events are written to the sentinel log.

Sentinel checks (every 5 min)
After every deploy, Ansible saves a SHA-256 checksum of the nginx config. The sentinel recomputes it every 5 minutes; any change raises an alert and logs it. If a service needs manual repair, re-running the Ansible playbook restores it from source control.
systemd timer config integrity auto-restart Ansible remediation
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 the VM: nginx, TLS, fail2ban,
# unattended-upgrades, MCP servers. All tasks are idempotent.
# API keys fetched from Azure Key Vault at boot via managed identity.
ansible-playbook site.yml -i inventory.ini
OpenTofu
Key Vault: auto-generated API keys
resource "random_password" "mcp_api_key" {
  length  = 64
  special = false
}

resource "azurerm_key_vault_secret" "mcp_api_key" {
  name         = "mcp-api-key"
  value        = random_password.mcp_api_key.result
  key_vault_id = azurerm_key_vault.kv.id
  # Never typed or stored outside Key Vault.
  # VM fetches at boot via managed identity (IMDS).
}
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
Managed identity: secrets at boot
# fetch-secrets.service runs before nginx and ask-app.
# Uses IMDS to get a token, then pulls from Key Vault.
# Writes to /run/ (tmpfs) — never touches persistent disk.
TOKEN=$(curl -sf -H "Metadata: true" \
  "http://169.254.169.254/metadata/identity/oauth2/token\
?resource=https://vault.azure.net" \
  | python3 -c \
  "import sys,json; print(json.load(sys.stdin)['access_token'])")

printf 'ANTHROPIC_API_KEY=%s\n' \
  "$(get_secret anthropic-api-key)" \
  > /run/secrets/ask-app.env
chmod 600 /run/secrets/ask-app.env