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.
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.
Loading...
| Host | |
| Domain | jrickey.cc |
| Region | Azure South Central US |
| Protocol | HTTP/2 · TLS 1.3 only |
| Certificate | Let's Encrypt (auto-renew) |
| Virtual Machines | |
| App VM | Standard_B2pts_v2 · ARM64 |
| DB VM | Standard_B2pts_v2 · ARM64 |
| OS | Ubuntu 24.04 LTS (arm64) |
| Auth | SSH key only (Ed25519), no passwords |
| SSH KEx | sntrup761x25519 (PQ hybrid) + classical fallback |
| Network | |
| VNet | 10.0.0.0/16 |
| App subnet | 10.0.1.0/24 (public IP) |
| DB subnet | 10.0.2.0/24 (no public IP) |
| DB port 3306 | App subnet only (NSG rule) |
| DB SSH | App subnet only (bastion pattern) |
| Automation Stack | |
| Provisioning | OpenTofu |
| Configuration | Ansible |
| Secrets | Azure Key Vault (auto-generated, runtime fetch) |
| OpenTofu state | Azure Blob Storage (shared, locked) |
| Monitoring | Azure Monitor + email alerts |
| DNS | Azure DNS · DNSSEC signed |
| AI interface | MCP over Streamable HTTP |
- 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/24only. 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.
local_file resource
writes the live IP into Ansible's inventory on every apply.
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.
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.
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.
/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.
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.
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.
| Browser | |
| OS | |
| Language | |
| Screen | |
| CPU threads | |
| Cookies | |
| Page load | |
| User agent |
system_info | Uptime, load averages (1m/5m/15m), memory, and disk usage |
nginx_stats | Active connections and request counts from nginx stub_status |
write_activity | Logs a summary of each Claude session to the Claude Activity panel |
~/.claude/mcp.json:
{
"mcpServers": {
"cloud-infra": {
"type": "http",
"url": "https://jrickey.cc/mcp/"
}
}
}
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.
- 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
get_sentinel_log— read recent health eventscheck_health— live service statusget_nginx_errors— nginx error logapply_remediation— run repair_web.yml
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.
| Uptime | — |
| Load avg (1m / 5m / 15m) | — |
| Memory | — |
| Disk | — |
| Nginx connections | — |
| Last updated by Claude | — |
# Creates VNet, subnets, NSGs, VMs, Key Vault, Monitor alerts, # and writes inventory.ini + group_vars with the live public IP. tofu apply -auto-approve
# 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
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" } }
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" }
# 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
# 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 }}
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.
Loading scan results...