Azure Deployment Runbook - VM Attached Storage

January 4, 2026 · View on GitHub

Deployment Type: vm-attached-storage

This deployment type creates a single Azure VM running all Keystone services (PostgreSQL, Keycloak, Backend, Frontend) with an attached Managed Disk for data persistence.

Best suited for:

  • Small to medium deployments (< 100 concurrent users)
  • Development/staging environments
  • Cost-conscious production deployments
  • Single-region deployments

Estimated cost: ~€35/month (francecentral, Standard_D2s_v3)


(i) Prerequisites

Azure Resources (Manual Setup - Permanent)

These resources must be created manually before deployment and will persist across infrastructure destroy/recreate cycles:

ResourcePurposeHow to Create
Azure AccountInfrastructure hostingAzure Portal
Resource GroupContainer for permanent resourcesSee below
Public IPStatic public IPSee below
Storage AccountSSL certificate persistenceSee below
Domain NamePublic access URLPurchase via registrar, point A record to Public IP

Create Permanent Resources

# Set variables
LOCATION="francecentral"
PERMANENT_RG="keystone-permanent-rg"
STORAGE_ACCOUNT="keystonecaddycerts"  # Must be globally unique, lowercase, no hyphens

# Create permanent resource group
az group create --name $PERMANENT_RG --location $LOCATION

# Create static public IP
az network public-ip create \
  --resource-group $PERMANENT_RG \
  --name keystone-public-ip \
  --sku Standard \
  --allocation-method Static \
  --location $LOCATION

# Get the IP address
az network public-ip show \
  --resource-group $PERMANENT_RG \
  --name keystone-public-ip \
  --query ipAddress -o tsv

# Create storage account for SSL certificates
az storage account create \
  --name $STORAGE_ACCOUNT \
  --resource-group $PERMANENT_RG \
  --location $LOCATION \
  --sku Standard_LRS \
  --kind StorageV2

# Create container for Caddy certificates
az storage container create \
  --name caddy \
  --account-name $STORAGE_ACCOUNT

Important: Create these resources manually (outside OpenTofu) so certificates and IP survive infrastructure destroy/recreate cycles.

Configure DNS

Point your domain's A record to the Public IP address:

azure.example.com → <public-ip-address>

Verify DNS propagation:

dig +short azure.example.com

Local Tools Required

ToolVersionInstallation
Azure CLI2.xbrew install azure-cli or Azure docs
OpenTofu1.6+brew install opentofu
Git2.xbrew install git
Make3.x+Pre-installed on macOS/Linux

Azure CLI Configuration

az login
az account show  # Verify subscription

Required Azure Permissions

The user/service principal needs permissions for:

  • Compute (Virtual Machines, Disks)
  • Network (Virtual Networks, NICs, NSGs, Public IPs)
  • Storage (Blob Storage read for permanent resources)
  • Managed Identity (for VM to access Storage Account)

(ii) Architecture

Infrastructure Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                         Azure Cloud                                  │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │              Virtual Network (10.0.0.0/16)                    │  │
│  │  ┌─────────────────────────────────────────────────────────┐  │  │
│  │  │                 Subnet (10.0.1.0/24)                    │  │  │
│  │  │                                                         │  │  │
│  │  │  ┌─────────────────────────────────────────────────┐   │  │  │
│  │  │  │         VM (Standard_D2s_v3)                    │   │  │  │
│  │  │  │                                                 │   │  │  │
│  │  │  │  ┌─────────────────────────────────────────┐   │   │  │  │
│  │  │  │  │              Docker                      │   │   │  │  │
│  │  │  │  │  ┌─────────┐ ┌─────────┐ ┌─────────┐   │   │   │  │  │
│  │  │  │  │  │PostgreSQL│ │Keycloak │ │ Backend │   │   │   │  │  │
│  │  │  │  │  │  :5432   │ │  :8080  │ │  :8000  │   │   │   │  │  │
│  │  │  │  │  └─────────┘ └─────────┘ └─────────┘   │   │   │  │  │
│  │  │  │  │  ┌─────────┐                           │   │   │  │  │
│  │  │  │  │  │Frontend │                           │   │   │  │  │
│  │  │  │  │  │  :3000  │                           │   │   │  │  │
│  │  │  │  │  └─────────┘                           │   │   │  │  │
│  │  │  │  └─────────────────────────────────────────┘   │   │  │  │
│  │  │  │                                                 │   │  │  │
│  │  │  │  ┌─────────────────────────────────────────┐   │   │  │  │
│  │  │  │  │    Caddy (Reverse Proxy + SSL)          │   │   │  │  │
│  │  │  │  │    :80, :443 → Backend/Keycloak/Frontend│   │   │  │  │
│  │  │  │  └─────────────────────────────────────────┘   │   │  │  │
│  │  │  └─────────────────────────────────────────────────┘   │  │  │
│  │  │           │                                             │  │  │
│  │  │           │ /dev/sdc                                   │  │  │
│  │  │           ▼                                             │  │  │
│  │  │  ┌─────────────────┐                                   │  │  │
│  │  │  │  Managed Disk   │                                   │  │  │
│  │  │  │  20GB StandardSSD│                                   │  │  │
│  │  │  │ /data/postgres/ │                                   │  │  │
│  │  │  └─────────────────┘                                   │  │  │
│  │  └─────────────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌────────────┐    ┌────────────────┐    ┌─────────────────────┐   │
│  │ Public IP  │    │      NSG       │    │  Storage Account    │   │
│  │ (static)   │    │ 80, 443 open   │    │ (SSL certificates)  │   │
│  └────────────┘    └────────────────┘    └─────────────────────┘   │
│   (permanent)                              (permanent)              │
└─────────────────────────────────────────────────────────────────────┘

                              │ HTTPS :443

                     ┌─────────────────┐
                     │   Internet      │
                     │   Users         │
                     └─────────────────┘

Components

ComponentTechnologyPortPurpose
Reverse ProxyCaddy80, 443SSL termination, routing
FrontendNext.js 153000Web UI
BackendFastAPI + Agno8000AI agent, API
AuthKeycloak8080OAuth2/OIDC
DatabasePostgreSQL 16 + PgVector5432Data storage

Data Persistence

DataLocationPersistence
PostgreSQL data/data/postgres/pgdata (Managed Disk)Survives VM deletion
SSL certificatesAzure Blob StorageSurvives infrastructure destroy
Application code/opt/keystone (OS disk)Rebuilt on deploy
Docker imagesOS diskRebuilt on deploy

Network Flow

User Request → Public IP → Caddy (:443)

                              ├── /realms/* → Keycloak (:8080)
                              ├── /resources/* → Keycloak (:8080)
                              ├── /api/* → Backend (:8000)
                              ├── /agui/* → Backend (:8000)
                              └── /* → Frontend (:3000)

(iii) Deploy From Scratch

Step 1: Clone Repository

git clone git@github.com:fulltechfactory/keystone.git
cd keystone

Step 2: Configure Production Environment

make setup-deploy

Answer the prompts:

PromptExample ValueNotes
Cloud providerazureSelect option 3
Infrastructure typevm-attached-storageSingle VM deployment
RegionfrancecentralSame as permanent resources
Domain nameazure.example.comMust point to your Public IP
Azure Subscription ID(auto-detected)From az account show
Permanent Resource Groupkeystone-permanent-rgCreated in prerequisites
Public IP namekeystone-public-ipCreated in prerequisites
Storage AccountkeystonecaddycertsCreated in prerequisites
Storage ContainercaddyCreated in prerequisites
AI provideropenaiRequired for RAG embeddings
AI API keysk-...Your OpenAI API key
Database passwords(generate strong)No @, :, /, # characters
Keystone admin password(generate strong)For adminuser login
Keycloak admin password(generate strong)For Keycloak console

Step 3: Initialize OpenTofu

make infra-init

Step 4: Preview Infrastructure

make infra-plan

Review the plan. Expected resources:

  • 1 Resource Group (for infrastructure)
  • 1 Virtual Network + Subnet
  • 1 Network Security Group (ports 80, 443)
  • 1 Network Interface
  • 1 Managed Identity + Role Assignment
  • 1 VM (Standard_D2s_v3)
  • 1 Managed Disk (20GB)

Step 5: Deploy Infrastructure

make infra-apply

Deployment takes ~5-10 minutes.

Step 6: Monitor Deployment

# Get SSH key (if auto-generated)
cd infra/azure/vm-attached-storage
tofu output -raw generated_ssh_private_key > ~/.ssh/azure-keystone.pem
chmod 600 ~/.ssh/azure-keystone.pem

# get the SSH key
cd infra/azure/vm-attached-storage
tofu output -raw generated_ssh_private_key > ~/.ssh/azure-keystone.pem
chmod 600 ~/.ssh/azure-keystone.pem

# Get public IP
tofu output public_ip

# Delete the old one
ssh-keygen -R <public-ip>

# Connect via SSH
ssh -i ~/.ssh/azure-keystone.pem azureuser@<public-ip>

# Watch deployment logs
sudo tail -f /var/log/user-data.log

Wait for: === Keystone setup complete on Azure ===

Step 7: Verify Deployment

# On the server
sudo docker ps  # All 3 containers running
sudo systemctl status keystone-frontend  # Frontend active
sudo systemctl status caddy  # Caddy active

# From your machine
curl -I https://your-domain.com  # Should return 200

Step 8: Access Application

  1. Open https://your-domain.com
  2. Sign in with adminuser / <your-keystone-admin-password>
  3. Create groups, users, and start using the application

(iv) Common Operations

Connect to Server

Via SSH:

ssh -i ~/.ssh/azure-keystone.pem azureuser@<public-ip>

Via Azure Serial Console:

  1. Portal Azure → Virtual Machines → keystone-prod-vm
  2. Help → Serial console
  3. Login with azureuser (may need to reset password first)

View Logs

# All Docker services
sudo docker compose -f /opt/keystone/docker-compose.yml logs -f

# Individual services
sudo docker logs keystone-backend -f
sudo docker logs keystone-keycloak -f
sudo docker logs keystone-postgres -f

# Frontend (systemd)
sudo journalctl -u keystone-frontend -f

# Caddy
sudo journalctl -u caddy -f

# Deployment log
sudo cat /var/log/user-data.log

Restart Services

# All Docker services
cd /opt/keystone
sudo docker compose restart

# Frontend
sudo systemctl restart keystone-frontend

# Caddy
sudo systemctl restart caddy

Update Application

# Connect to server
ssh -i ~/.ssh/azure-keystone.pem azureuser@<public-ip>

# Pull latest code and rebuild
cd /opt/keystone
sudo git pull
sudo docker compose build --no-cache
sudo docker compose up -d

# Rebuild frontend
cd frontend
sudo pnpm install
sudo pnpm build --webpack
sudo systemctl restart keystone-frontend

Destroy Infrastructure

# From local machine
make infra-destroy

Note: This destroys the VM and infrastructure Resource Group but preserves:

  • Public IP (in permanent Resource Group)
  • Storage Account with SSL certificates
  • DNS configuration

Rollback Procedure

# Connect to server
ssh -i ~/.ssh/azure-keystone.pem azureuser@<public-ip>

# Rollback to specific commit
cd /opt/keystone
sudo git fetch
sudo git checkout <commit-hash>
sudo docker compose down
sudo docker compose build --no-cache
sudo docker compose up -d

(v) Troubleshooting Runbook

Quick Health Check

# Connect to server
ssh -i ~/.ssh/azure-keystone.pem azureuser@<public-ip>

# Check all services
sudo docker ps
sudo systemctl status keystone-frontend
sudo systemctl status caddy
df -h /data/postgres  # Managed Disk mounted?
curl -s http://localhost:8000/health | jq  # Backend healthy?
curl -s http://localhost:3000  # Frontend responding?

Problem: Cannot Connect to Server

Symptoms: SSH times out, connection refused

Investigation:

# Check VM status
az vm show --resource-group keystone-prod-rg --name keystone-prod-vm --query powerState

# Check NSG rules
az network nsg rule list --resource-group keystone-prod-rg --nsg-name keystone-prod-nsg -o table

Solutions:

  1. VM stopped → Start via Azure portal or az vm start
  2. VM not found → Run make infra-apply to recreate
  3. SSH port blocked → Add NSG rule for port 22 (see below)

Add SSH rule (if needed):

az network nsg rule create \
  --resource-group keystone-prod-rg \
  --nsg-name keystone-prod-nsg \
  --name AllowSSH \
  --priority 100 \
  --source-address-prefixes '<your-ip>/32' \
  --destination-port-ranges 22 \
  --protocol Tcp

Problem: Website Not Loading (Connection Refused)

Symptoms: Browser shows "Connection refused" or timeout

Investigation:

# On server
sudo systemctl status caddy
sudo journalctl -u caddy -n 50
sudo docker ps

Solutions:

CauseSolution
Caddy not runningsudo systemctl start caddy
Caddy config errorCheck /etc/caddy/Caddyfile, then sudo systemctl restart caddy
NSG blocking portsVerify ports 80, 443 are open in Azure portal
Docker not runningsudo systemctl start docker && cd /opt/keystone && sudo docker compose up -d

Problem: SSL Certificate Error

Symptoms: Browser shows "Invalid certificate" or "Not secure"

Investigation:

sudo journalctl -u caddy -n 100 | grep -i "certificate\|acme\|tls"

Solutions:

CauseSolution
Rate limited (429)Wait 1 week, certificates are cached in Azure Blob
DNS not propagatedVerify A record: dig +short your-domain.com
Blob access deniedCheck VM Managed Identity has Storage Blob Data Contributor role
Wrong domainVerify domain_name in .deploy-config

Force certificate renewal:

sudo systemctl stop caddy
sudo rm -rf /data/caddy/certificates
sudo /usr/local/bin/caddy-cert-download  # Download from Blob if exists
sudo systemctl start caddy

Problem: "Sign in with Keycloak" Error

Symptoms: Login redirects to error page

Investigation:

sudo docker logs keystone-keycloak 2>&1 | tail -50
sudo docker logs keystone-keycloak-setup 2>&1

Solutions:

CauseSolution
Keycloak not readyWait 1-2 minutes, check sudo docker ps
Redirect URI mismatchCheck Keycloak admin console → Clients → keystone-app → Valid Redirect URIs
Database connectionCheck PostgreSQL: sudo docker logs keystone-postgres

Problem: Keycloak Cannot Connect to Database

Symptoms: password authentication failed for user "keycloak"

Investigation:

sudo docker logs keystone-keycloak 2>&1 | grep -i "password\|authentication\|database"

Solutions:

# Reset Keycloak password in PostgreSQL
source /opt/keystone/.deploy-config
sudo docker exec keystone-postgres psql -U postgres -c \
  "ALTER USER keycloak WITH PASSWORD '$DB_KEYCLOAK_PASSWORD';"
sudo docker compose restart keycloak

Problem: Frontend Build Failed (Turbopack Error)

Symptoms: Frontend not starting, Turbopack errors in logs

Investigation:

sudo journalctl -u keystone-frontend -n 100
cat /var/log/user-data.log | grep -i "build\|error"

Solution:

cd /opt/keystone/frontend
sudo pnpm build --webpack  # Use webpack instead of turbopack
sudo systemctl restart keystone-frontend

Problem: RAG Not Working

Symptoms: Chat doesn't find documents, "No relevant information found"

Investigation:

# Check backend logs
sudo docker logs keystone-backend 2>&1 | grep -i "knowledge\|search\|error"

# Check if documents exist
sudo docker exec keystone-postgres psql -U postgres -d keystone_db -c \
  "SELECT COUNT(*) FROM app.knowledge_embeddings;"

Solutions:

CauseSolution
No documents uploadedUpload documents via Knowledge Base UI
OpenAI API key invalidCheck .deploy-config, restart backend
User not in groupAdd user to group with documents
Missing permissionsGrant READ/WRITE permission in KB Management

Problem: PostgreSQL Data Lost

Symptoms: Users, groups, documents missing after restart

Investigation:

# Check Managed Disk mounted
df -h /data/postgres
ls -la /data/postgres/pgdata/

# Check docker-compose override
cat /opt/keystone/docker-compose.override.yml | grep -A3 postgres

Solutions:

CauseSolution
Disk not mountedsudo mount /dev/sdc /data/postgres
Using Docker volumeFix docker-compose.override.yml to use /data/postgres/pgdata
Data corruptedRestore from backup (if available)

Problem: High Memory/CPU Usage

Symptoms: Application slow, VM unresponsive

Investigation:

htop  # or top
sudo docker stats
df -h  # Disk full?

Solutions:

CauseSolution
Memory exhaustedUpgrade to larger VM size
Disk fullClean Docker: sudo docker system prune -a
Runaway processsudo docker compose restart

Useful Commands Reference

# Service Management
sudo docker compose up -d          # Start all Docker services
sudo docker compose down           # Stop all Docker services
sudo docker compose restart        # Restart all Docker services
sudo docker compose logs -f        # Follow all logs

# Individual Service Logs
sudo docker logs keystone-postgres
sudo docker logs keystone-keycloak
sudo docker logs keystone-backend
sudo journalctl -u keystone-frontend -f
sudo journalctl -u caddy -f

# Database Access
sudo docker exec -it keystone-postgres psql -U postgres -d keystone_db

# Check Configurations
cat /opt/keystone/.deploy-config
cat /opt/keystone/docker-compose.override.yml
cat /etc/caddy/Caddyfile

# Certificate Management
sudo /usr/local/bin/caddy-cert-upload    # Upload certs to Blob
sudo /usr/local/bin/caddy-cert-download  # Download certs from Blob

# Disk and Memory
df -h
free -m
sudo docker system df

# Azure CLI from VM (using Managed Identity)
az login --identity
az storage blob list --account-name keystonecaddycerts --container-name caddy --auth-mode login

Appendix: File Locations

FileLocationPurpose
Application code/opt/keystone/Git repository
Deploy config/opt/keystone/.deploy-configEnvironment variables
Docker override/opt/keystone/docker-compose.override.ymlProduction overrides
PostgreSQL data/data/postgres/pgdata/Database files (Managed Disk)
Caddy config/etc/caddy/CaddyfileReverse proxy config
Caddy data/data/caddy/Certificates (local cache)
Cert upload script/usr/local/bin/caddy-cert-uploadSync certs to Blob
Cert download script/usr/local/bin/caddy-cert-downloadSync certs from Blob
User-data log/var/log/user-data.logDeployment log
SSL certificatesAzure Blob StorageManaged by sync scripts

Appendix: Port Reference

PortServiceAccess
22SSHOptional (add NSG rule)
80Caddy HTTPPublic (redirects to 443)
443Caddy HTTPSPublic
3000FrontendInternal only
5432PostgreSQLInternal only
8000BackendInternal only
8080KeycloakInternal only

Appendix: Azure Resource Mapping

AWS EquivalentAzure ResourceNotes
VPCVirtual Network10.0.0.0/16
SubnetSubnet10.0.1.0/24
Security GroupNetwork Security Group (NSG)
Elastic IPPublic IP (Static)In permanent RG
EC2 InstanceVirtual MachineStandard_D2s_v3
EBS VolumeManaged DiskStandardSSD, 20GB
IAM RoleManaged IdentitySystem-assigned
S3 BucketStorage Account + ContainerIn permanent RG
SSM Session ManagerSSH + Serial Console