diff --git a/terraform/azure/README.md b/terraform/azure/README.md new file mode 100644 index 000000000..32fd12d31 --- /dev/null +++ b/terraform/azure/README.md @@ -0,0 +1,103 @@ +Azure VM + Nginx Terraform Scaffold +=================================== + +This Terraform configuration deploys a production‑grade baseline on Azure: +- Resource Group +- Virtual Network + subnets (web, optional Bastion) +- Network Security Group with least‑privilege inbound rules +- Public Standard Load Balancer (HTTP, optional HTTPS passthrough) +- Linux VM Scale Set (Ubuntu 22.04) behind the LB +- Cloud‑init to install and configure Nginx + PHP‑FPM +- Optional Azure Bastion for secure SSH access (recommended) + +Notes +----- +- This is a scaffold. It provisions compute and networking to run a PHP/Nginx app. It does not include database/storage/caching. Integrate Azure Database for MySQL or Postgres, Azure Files/Disks, Redis, etc., as needed. +- HTTPS is exposed at the LB and Nginx layers (port 443) if enabled, but certificate provisioning/renewal is not included. Consider using Azure Application Gateway + Key Vault or integrate certbot/Key Vault in cloud‑init/CM tooling. +- Remote Terraform state is recommended. Configure the azurerm backend via backend config before `terraform init`. + +Prerequisites +------------- +- Terraform >= 1.5 +- Azure CLI (`az`) authenticated to your subscription +- An SSH public key + +Quick Start +----------- +1) Configure backend (recommended) + +Create or reuse a Storage Account and container for state (one‑time): +- Resource Group: rg-tfstate +- Storage Account: sttfstate<unique> +- Container: tfstate + +Then either: +- Provide `-backend-config` flags on `terraform init`, or +- Create a `backend.hcl` file: + + storage_account_name = "sttfstate<unique>" + container_name = "tfstate" + key = "myapp-dev.tfstate" + resource_group_name = "rg-tfstate" + +2) Initialize, plan, and apply + + cd terraform/azure + cp terraform.tfvars.example terraform.tfvars + # edit terraform.tfvars to suit your environment + + # init (with backend) + terraform init -backend-config=../backend.hcl + + # or init without a backend (local state) + # terraform init + + terraform plan + terraform apply + +3) Access +- Public endpoint: + - IP: output `lb_public_ip` + - FQDN (if `dns_label_prefix` provided): output `lb_fqdn` +- SSH: + - Recommended: use Azure Bastion (set `create_bastion = true`). Open the VMSS instance via the Azure Portal -> Bastion. + - Direct SSH: not recommended; if you must, set `allowed_ssh_cidrs` and access via per‑instance private IP through a jump host or add an inbound NAT pool to the LB. + +Configuration +------------- +Edit `terraform.tfvars`: +- name_prefix: short project prefix (e.g. "myapp") +- environment: dev|staging|prod +- location: Azure region (e.g. eastus) +- address_space, web_subnet_cidr: VNet/Subnet CIDRs +- create_bastion: true|false +- ssh_public_key: your SSH public key +- vm_size, instance_count, zones: sizing and HA +- dns_label_prefix: optional public DNS label (produces <label>-<rand>.<region>.cloudapp.azure.com) +- enable_https: expose 443 (certificate management not included) +- tags: map of tags + +What gets deployed +------------------ +- Standard Public IP with optional DNS label and Standard Load Balancer +- Backend pool + health probes (HTTP 80) and LB rules (80; optional 443 passthrough) +- Ubuntu 22.04 VMSS joined to the backend pool +- Cloud‑init that: + - Installs Nginx + PHP‑FPM and common PHP extensions + - Creates a basic Nginx vhost pointing to /var/www/app/public + - Places a simple index.php health page + - Enables and restarts services + +Next steps (app deployment) +--------------------------- +- Replace cloud‑init with a stronger provisioning approach: + - Build a custom image (Packer) with Nginx/PHP preinstalled + - Use CM tooling (Ansible/Chef/Puppet) or an Azure VM Extension to deploy your app + - Mount Azure Files or attach managed disks for persistent storage +- Add a managed database (Azure Database for MySQL/Postgres) and secure connectivity +- Add Key Vault for secrets/certificates +- Consider Application Gateway (WAF) for TLS termination and L7 routing + +Destroy +------- + terraform destroy \ No newline at end of file diff --git a/terraform/azure/backend.hcl.example b/terraform/azure/backend.hcl.example new file mode 100644 index 000000000..79482fcc5 --- /dev/null +++ b/terraform/azure/backend.hcl.example @@ -0,0 +1,5 @@ +# Example backend configuration for remote Terraform state in Azure Storage +resource_group_name = "rg-tfstate" +storage_account_name = "sttfstateyourunique" +container_name = "tfstate" +key = "myapp-dev.tfstate" \ No newline at end of file diff --git a/terraform/azure/bastion.tf b/terraform/azure/bastion.tf new file mode 100644 index 000000000..318450742 --- /dev/null +++ b/terraform/azure/bastion.tf @@ -0,0 +1,24 @@ +resource "azurerm_public_ip" "bastion" { + count = var.create_bastion ? 1 : 0 + name = "${local.base_name}-bastion-pip" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + allocation_method = "Static" + sku = "Standard" + tags = local.common_tags +} + +resource "azurerm_bastion_host" "bastion" { + count = var.create_bastion ? 1 : 0 + name = "${local.base_name}-bastion" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + + ip_configuration { + name = "configuration" + subnet_id = azurerm_subnet.bastion[0].id + public_ip_address_id = azurerm_public_ip.bastion[0].id + } + + tags = local.common_tags +} \ No newline at end of file diff --git a/terraform/azure/cloud-init.tpl b/terraform/azure/cloud-init.tpl new file mode 100644 index 000000000..a08027a74 --- /dev/null +++ b/terraform/azure/cloud-init.tpl @@ -0,0 +1,68 @@ +#cloud-config +package_update: true +package_upgrade: true + +packages: + - nginx + - php-fpm + - php-cli + - php-common + - php-curl + - php-gd + - php-mbstring + - php-xml + - php-zip + - php-intl + - php-mysql + - unzip + - git + +write_files: + - path: /etc/nginx/sites-available/app.conf + content: | + server { + listen 80; + listen [::]:80; + + server_name ${server_name}; + + root /var/www/app/public; + index index.php index.html index.htm; + + access_log /var/log/nginx/app_access.log; + error_log /var/log/nginx/app_error.log; + + location / { + try_files $uri /index.php?$query_string; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + # Ubuntu 22.04 default PHP-FPM socket + fastcgi_pass unix:/run/php/php8.1-fpm.sock; + } + + location ~* \.(jpg|jpeg|gif|png|css|js|ico|webp|tiff)$ { + expires 30d; + access_log off; + } + + client_max_body_size 64M; + } + permissions: "0644" + + - path: /var/www/app/public/index.php + content: | + 0 ? var.zones : null + + admin_ssh_key { + username = var.admin_username + public_key = var.ssh_public_key + } + + identity { + type = "SystemAssigned" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } + + os_disk { + storage_account_type = "Standard_LRS" + caching = "ReadWrite" + } + + upgrade_mode = "Rolling" + + # Cloud-init to install and configure Nginx + PHP-FPM + custom_data = base64encode( + templatefile("${path.module}/cloud-init.tpl", { + server_name = trim(var.dns_label_prefix) != "" ? azurerm_public_ip.lb.fqdn : "_" + enable_https = var.enable_https + }) + ) + + network_interface { + name = "${local.vmss_name}-nic" + primary = true + + ip_configuration { + name = "internal" + primary = true + subnet_id = azurerm_subnet.web.id + load_balancer_backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id] + } + } + + boot_diagnostics { + storage_account_uri = null + } + + tags = local.common_tags +} \ No newline at end of file diff --git a/terraform/azure/loadbalancer.tf b/terraform/azure/loadbalancer.tf new file mode 100644 index 000000000..40211e9ec --- /dev/null +++ b/terraform/azure/loadbalancer.tf @@ -0,0 +1,78 @@ +resource "random_string" "suffix" { + length = 5 + upper = false + special = false +} + +resource "azurerm_public_ip" "lb" { + name = "${local.base_name}-lb-pip" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + allocation_method = "Static" + sku = "Standard" + + domain_name_label = trim(var.dns_label_prefix) != "" ? "${var.dns_label_prefix}-${random_string.suffix.result}" : null + + tags = local.common_tags +} + +resource "azurerm_lb" "web" { + name = "${local.base_name}-lb" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + sku = "Standard" + + frontend_ip_configuration { + name = "PublicFrontend" + public_ip_address_id = azurerm_public_ip.lb.id + } + + tags = local.common_tags +} + +resource "azurerm_lb_backend_address_pool" "web" { + name = "${local.base_name}-bepool" + loadbalancer_id = azurerm_lb.web.id +} + +resource "azurerm_lb_probe" "http" { + name = "http" + resource_group_name = azurerm_resource_group.rg.name + loadbalancer_id = azurerm_lb.web.id + protocol = "Tcp" + port = 80 +} + +resource "azurerm_lb_rule" "http" { + name = "http" + resource_group_name = azurerm_resource_group.rg.name + loadbalancer_id = azurerm_lb.web.id + protocol = "Tcp" + frontend_port = 80 + backend_port = 80 + frontend_ip_configuration_name = "PublicFrontend" + backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id] + probe_id = azurerm_lb_probe.http.id +} + +resource "azurerm_lb_probe" "https" { + count = var.enable_https ? 1 : 0 + name = "https" + resource_group_name = azurerm_resource_group.rg.name + loadbalancer_id = azurerm_lb.web.id + protocol = "Tcp" + port = 443 +} + +resource "azurerm_lb_rule" "https" { + count = var.enable_https ? 1 : 0 + name = "https" + resource_group_name = azurerm_resource_group.rg.name + loadbalancer_id = azurerm_lb.web.id + protocol = "Tcp" + frontend_port = 443 + backend_port = 443 + frontend_ip_configuration_name = "PublicFrontend" + backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id] + probe_id = azurerm_lb_probe.https[0].id +} \ No newline at end of file diff --git a/terraform/azure/network.tf b/terraform/azure/network.tf new file mode 100644 index 000000000..c3c6131e3 --- /dev/null +++ b/terraform/azure/network.tf @@ -0,0 +1,23 @@ +resource "azurerm_virtual_network" "vnet" { + name = "${local.base_name}-vnet" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + address_space = var.address_space + tags = local.common_tags +} + +resource "azurerm_subnet" "web" { + name = "${local.base_name}-web-sn" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = [var.web_subnet_cidr] +} + +# Optional subnet for Azure Bastion (required if create_bastion = true) +resource "azurerm_subnet" "bastion" { + count = var.create_bastion ? 1 : 0 + name = "AzureBastionSubnet" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = [var.bastion_subnet_cidr] +} \ No newline at end of file diff --git a/terraform/azure/outputs.tf b/terraform/azure/outputs.tf new file mode 100644 index 000000000..bbdced4cb --- /dev/null +++ b/terraform/azure/outputs.tf @@ -0,0 +1,29 @@ +output "resource_group_name" { + description = "Name of the resource group." + value = azurerm_resource_group.rg.name +} + +output "location" { + description = "Azure region." + value = azurerm_resource_group.rg.location +} + +output "lb_public_ip" { + description = "Public IP address of the load balancer." + value = azurerm_public_ip.lb.ip_address +} + +output "lb_fqdn" { + description = "FQDN of the load balancer public IP (if a DNS label prefix was provided)." + value = azurerm_public_ip.lb.fqdn +} + +output "bastion_id" { + description = "Azure Bastion resource ID (if created)." + value = var.create_bastion ? azurerm_bastion_host.bastion[0].id : null +} + +output "vmss_name" { + description = "Name of the VM Scale Set." + value = azurerm_linux_virtual_machine_scale_set.web.name +} \ No newline at end of file diff --git a/terraform/azure/providers.tf b/terraform/azure/providers.tf new file mode 100644 index 000000000..615cb43d9 --- /dev/null +++ b/terraform/azure/providers.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} \ No newline at end of file diff --git a/terraform/azure/resource_group.tf b/terraform/azure/resource_group.tf new file mode 100644 index 000000000..79569c7c9 --- /dev/null +++ b/terraform/azure/resource_group.tf @@ -0,0 +1,18 @@ +locals { + common_tags = merge( + { + "env" = var.environment + "project" = var.name_prefix + "owner" = "terraform" + }, + var.tags + ) + + base_name = "${var.name_prefix}-${var.environment}" +} + +resource "azurerm_resource_group" "rg" { + name = "${local.base_name}-rg" + location = var.location + tags = local.common_tags +} \ No newline at end of file diff --git a/terraform/azure/security.tf b/terraform/azure/security.tf new file mode 100644 index 000000000..204aff9bb --- /dev/null +++ b/terraform/azure/security.tf @@ -0,0 +1,69 @@ +resource "azurerm_network_security_group" "web" { + name = "${local.base_name}-web-nsg" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = local.common_tags + + security_rule { + name = "Allow-HTTP-from-AzureLoadBalancer" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "80" + source_address_prefix = "AzureLoadBalancer" + destination_address_prefix = "*" + } + + dynamic "security_rule" { + for_each = var.enable_https ? [1] : [] + content { + name = "Allow-HTTPS-from-AzureLoadBalancer" + priority = 110 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "AzureLoadBalancer" + destination_address_prefix = "*" + } + } + + # Optional: allow direct SSH from specific CIDRs (not recommended; use Bastion) + dynamic "security_rule" { + for_each = length(var.allowed_ssh_cidrs) > 0 ? [1] : [] + content { + name = "Allow-SSH-from-Trusted" + priority = 200 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefixes = var.allowed_ssh_cidrs + destination_address_prefix = "*" + # If using LB Inbound NAT for SSH, traffic will also appear from AzureLoadBalancer. + # Adjust as needed based on your access strategy. + } + } + + # Allow intra-VNet traffic + security_rule { + name = "Allow-Intra-VNet" + priority = 300 + direction = "Inbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "VirtualNetwork" + destination_address_prefix = "VirtualNetwork" + } +} + +resource "azurerm_subnet_network_security_group_association" "web" { + subnet_id = azurerm_subnet.web.id + network_security_group_id = azurerm_network_security_group.web.id +} \ No newline at end of file diff --git a/terraform/azure/terraform.tfvars.example b/terraform/azure/terraform.tfvars.example new file mode 100644 index 000000000..6f06a7248 --- /dev/null +++ b/terraform/azure/terraform.tfvars.example @@ -0,0 +1,31 @@ +# Example configuration values for Azure deployment + +name_prefix = "myapp" +environment = "dev" +location = "eastus" + +# Networking +address_space = ["10.10.0.0/16"] +web_subnet_cidr = "10.10.1.0/24" +bastion_subnet_cidr = "10.10.100.0/27" + +# Access +admin_username = "azureuser" +ssh_public_key = "ssh-rsa AAAA... replace with your public key ..." +create_bastion = true +allowed_ssh_cidrs = [] # e.g., ["203.0.113.0/24"] if you want to allow direct SSH (not recommended) + +# Compute +vm_size = "Standard_B2s" +instance_count = 2 +zones = ["1", "2", "3"] + +# Load balancer / DNS +dns_label_prefix = "myapp-dev" # optional; leave blank "" to skip public DNS +enable_https = false + +# Tags +tags = { + owner = "you@example.com" + cost_center = "web" +} \ No newline at end of file diff --git a/terraform/azure/variables.tf b/terraform/azure/variables.tf new file mode 100644 index 000000000..7c1a3575d --- /dev/null +++ b/terraform/azure/variables.tf @@ -0,0 +1,93 @@ +variable "name_prefix" { + description = "A short prefix used to name all resources (e.g., project or org name)." + type = string +} + +variable "environment" { + description = "Deployment environment (e.g., dev, staging, prod)." + type = string + default = "dev" +} + +variable "location" { + description = "Azure region to deploy resources into." + type = string + default = "eastus" +} + +variable "address_space" { + description = "Address space for the virtual network." + type = list(string) + default = ["10.10.0.0/16"] +} + +variable "web_subnet_cidr" { + description = "CIDR for the web subnet where VMSS instances will live." + type = string + default = "10.10.1.0/24" +} + +variable "bastion_subnet_cidr" { + description = "CIDR for the Bastion subnet (required if create_bastion = true). Must be at least /27." + type = string + default = "10.10.100.0/27" +} + +variable "create_bastion" { + description = "Whether to provision Azure Bastion for secure SSH access (recommended)." + type = bool + default = true +} + +variable "admin_username" { + description = "Admin username for the Linux VMs." + type = string + default = "azureuser" +} + +variable "ssh_public_key" { + description = "Public SSH key to access the VMs (required when create_bastion = false or for break-glass SSH)." + type = string +} + +variable "allowed_ssh_cidrs" { + description = "Optional list of CIDR ranges allowed to SSH directly to VMs (not recommended; use Bastion). If empty, no direct SSH rule will be created." + type = list(string) + default = [] +} + +variable "vm_size" { + description = "VM size for the web server VM Scale Set." + type = string + default = "Standard_B2s" +} + +variable "instance_count" { + description = "Number of VM instances in the scale set." + type = number + default = 2 +} + +variable "dns_label_prefix" { + description = "Optional prefix for the public IP DNS label (generates <prefix>-<rand>.<region>.cloudapp.azure.com). Leave empty to skip DNS." + type = string + default = "" +} + +variable "enable_https" { + description = "Open port 443 on the load balancer and VMs (certificate management not included)." + type = bool + default = false +} + +variable "zones" { + description = "Optional availability zones to use (if supported in the region). Example: [\"1\", \"2\", \"3\"]. Leave empty for no zone pinning." + type = list(string) + default = [] +} + +variable "tags" { + description = "A map of tags to apply to all resources." + type = map(string) + default = {} +} \ No newline at end of file diff --git a/terraform/azure/versions.tf b/terraform/azure/versions.tf new file mode 100644 index 000000000..3b762e8aa --- /dev/null +++ b/terraform/azure/versions.tf @@ -0,0 +1,17 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.113" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } + + # Recommended: use a remote backend for state. Configure via -backend-config or backend.hcl. + # backend "azurerm" {} +} \ No newline at end of file