diff --git a/infrastructure/modules/bastion/README.md b/infrastructure/modules/bastion/README.md
new file mode 100644
index 00000000..959a1808
--- /dev/null
+++ b/infrastructure/modules/bastion/README.md
@@ -0,0 +1,135 @@
+# bastion
+
+Deploy an [Azure Bastion host](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) with a dedicated public IP address and diagnostic settings. Integrates with the [subnet module](../subnet/) and [log-analytics-workspace module](../log-analytics-workspace/).
+
+## Terraform documentation
+
+For the list of inputs, outputs, resources... check the [terraform module documentation](tfdocs.md).
+
+## Prerequisites
+
+Azure Bastion requires a dedicated subnet named exactly `AzureBastionSubnet` with a minimum `/26` prefix. Use the [subnet module](../subnet/) to create it with the required NSG rules:
+
+```hcl
+module "subnet_bastion" {
+ source = "../subnet"
+
+ name = "AzureBastionSubnet"
+ resource_group_name = azurerm_resource_group.main.name
+ vnet_name = module.vnet.name
+ address_prefixes = [cidrsubnet(var.vnet_address_space, 10, 0)]
+ location = var.region
+ create_nsg = true
+ network_security_group_name = "nsg-bastion-${var.env_config}-uks"
+ network_security_group_nsg_rules = local.bastion_nsg_rules
+
+ log_analytics_workspace_id = module.log_analytics_workspace.id
+ monitor_diagnostic_setting_network_security_group_enabled_logs = []
+}
+```
+
+See [Azure Bastion NSG requirements](https://learn.microsoft.com/en-us/azure/bastion/bastion-nsg) for the required inbound and outbound rules.
+
+## Usage
+
+Deploy a Standard Bastion host with audit logging:
+
+```hcl
+module "bastion" {
+ source = "../../../dtos-devops-templates/infrastructure/modules/bastion"
+
+ name = "bas-myapp-dev-uks"
+ public_ip_name = "pip-bastion-myapp-dev-uks"
+ resource_group_name = azurerm_resource_group.main.name
+ location = "uksouth"
+ sku = "Standard"
+ subnet_id = module.subnet_bastion.id
+
+ log_analytics_workspace_id = module.log_analytics_workspace.id
+ monitor_diagnostic_setting_bastion_enabled_logs = ["BastionAuditLogs"]
+ monitor_diagnostic_setting_bastion_metrics = ["AllMetrics"]
+}
+```
+
+## SKU selection
+
+| SKU | Scale units | Native client | File copy | IP connect | Session recording | Public IP required |
+|-----|-------------|---------------|-----------|------------|-------------------|--------------------|
+| Basic | Fixed (2) | No | No | No | No | Yes |
+| Standard | 2–50 | Yes | Yes | Yes | No | Yes |
+| Premium | 2–50 | Yes | Yes | Yes | Yes | Yes |
+
+- **Standard** is recommended for most production workloads. It supports native client connections, host scaling, and all advanced features.
+- **Premium** adds session recording for compliance requirements and a private-only deployment option (no public IP).
+- **Basic** provides a fixed-capacity dedicated deployment at lower cost. Advanced features are not available.
+- **Developer** SKU is not supported by this module — it uses shared infrastructure and does not require a public IP or dedicated subnet.
+
+## Zone redundancy (recommended for production)
+
+By default the public IP is deployed with no availability zone pinning. For production, pass `zones = ["1", "2", "3"]` to make the public IP zone-redundant:
+
+```hcl
+module "bastion" {
+ ...
+ zones = ["1", "2", "3"]
+}
+```
+
+Zone-redundant public IPs are available in UK South at no additional cost. See [Reliability in Azure Bastion](https://learn.microsoft.com/en-us/azure/reliability/reliability-bastion) for supported regions.
+
+## Standard/Premium features
+
+The following features are disabled by default and require Standard SKU or higher:
+
+**Native client / tunneling** — enables SSH and RDP connections via the Azure CLI (`az network bastion ssh`, `az network bastion rdp`) instead of the browser-based client. Recommended for operations teams:
+
+```hcl
+module "bastion" {
+ ...
+ tunneling_enabled = true
+}
+```
+
+**IP connect** — allows connecting to VMs using their private IP address rather than requiring them to be in the same virtual network:
+
+```hcl
+module "bastion" {
+ ...
+ ip_connect_enabled = true
+}
+```
+
+**File copy** — enables file transfer between the local machine and target VMs via native client connections:
+
+```hcl
+module "bastion" {
+ ...
+ file_copy_enabled = true
+ tunneling_enabled = true # file copy requires native client
+}
+```
+
+## Scaling
+
+Each scale unit supports approximately 20 concurrent RDP sessions or 40 concurrent SSH sessions. The default of 2 units (40 RDP / 80 SSH) is sufficient for most environments. Increase `scale_units` for larger deployments:
+
+```hcl
+module "bastion" {
+ ...
+ scale_units = 5 # ~100 concurrent RDP / ~200 concurrent SSH
+}
+```
+
+`scale_units` is only configurable for Standard and Premium SKUs. Basic is fixed at 2 units.
+
+## Diagnostic logs
+
+The module creates a diagnostic setting targeting the provided Log Analytics workspace. The only available log category for Azure Bastion is `BastionAuditLogs`, which records user connections, source IPs, session metadata, and audit trail information:
+
+```hcl
+module "bastion" {
+ ...
+ monitor_diagnostic_setting_bastion_enabled_logs = ["BastionAuditLogs"]
+ monitor_diagnostic_setting_bastion_metrics = ["AllMetrics"]
+}
+```
diff --git a/infrastructure/modules/bastion/main.tf b/infrastructure/modules/bastion/main.tf
new file mode 100644
index 00000000..00b163cc
--- /dev/null
+++ b/infrastructure/modules/bastion/main.tf
@@ -0,0 +1,44 @@
+module "pip" {
+ source = "../public-ip"
+
+ name = var.public_ip_name
+ resource_group_name = var.resource_group_name
+ location = var.location
+ allocation_method = "Static"
+ sku = "Standard"
+ zones = var.zones
+
+ tags = var.tags
+}
+
+resource "azurerm_bastion_host" "bastion" {
+ name = var.name
+ location = var.location
+ resource_group_name = var.resource_group_name
+ sku = var.sku
+
+ copy_paste_enabled = var.copy_paste_enabled
+ file_copy_enabled = var.file_copy_enabled
+ ip_connect_enabled = var.ip_connect_enabled
+ tunneling_enabled = var.tunneling_enabled
+ shareable_link_enabled = var.shareable_link_enabled
+ scale_units = var.scale_units
+
+ ip_configuration {
+ name = "configuration"
+ subnet_id = var.subnet_id
+ public_ip_address_id = module.pip.id
+ }
+
+ tags = var.tags
+}
+
+module "diagnostic_settings" {
+ source = "../diagnostic-settings"
+
+ name = "${var.name}-diagnostic-setting"
+ target_resource_id = azurerm_bastion_host.bastion.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+ enabled_log = var.monitor_diagnostic_setting_bastion_enabled_logs
+ enabled_metric = var.monitor_diagnostic_setting_bastion_metrics
+}
diff --git a/infrastructure/modules/bastion/outputs.tf b/infrastructure/modules/bastion/outputs.tf
new file mode 100644
index 00000000..87538dfc
--- /dev/null
+++ b/infrastructure/modules/bastion/outputs.tf
@@ -0,0 +1,19 @@
+output "id" {
+ description = "The ID of the Bastion host."
+ value = azurerm_bastion_host.bastion.id
+}
+
+output "name" {
+ description = "The name of the Bastion host."
+ value = azurerm_bastion_host.bastion.name
+}
+
+output "dns_name" {
+ description = "The FQDN of the Bastion host."
+ value = azurerm_bastion_host.bastion.dns_name
+}
+
+output "public_ip_address" {
+ description = "The public IP address associated with the Bastion host."
+ value = module.pip.ip_address
+}
diff --git a/infrastructure/modules/bastion/tfdocs.md b/infrastructure/modules/bastion/tfdocs.md
new file mode 100644
index 00000000..a995f598
--- /dev/null
+++ b/infrastructure/modules/bastion/tfdocs.md
@@ -0,0 +1,170 @@
+# Module documentation
+
+## Required Inputs
+
+The following input variables are required:
+
+### [log\_analytics\_workspace\_id](#input\_log\_analytics\_workspace\_id)
+
+Description: The ID of the Log Analytics workspace to send diagnostic logs to.
+
+Type: `string`
+
+### [monitor\_diagnostic\_setting\_bastion\_enabled\_logs](#input\_monitor\_diagnostic\_setting\_bastion\_enabled\_logs)
+
+Description: List of log categories to enable for the Bastion diagnostic setting (e.g. ["BastionAuditLogs"]).
+
+Type: `list(string)`
+
+### [monitor\_diagnostic\_setting\_bastion\_metrics](#input\_monitor\_diagnostic\_setting\_bastion\_metrics)
+
+Description: List of metric categories to enable for the Bastion diagnostic setting (e.g. ["AllMetrics"]).
+
+Type: `list(string)`
+
+### [name](#input\_name)
+
+Description: The name of the Azure Bastion host.
+
+Type: `string`
+
+### [public\_ip\_name](#input\_public\_ip\_name)
+
+Description: The name of the public IP address resource created for the Bastion host.
+
+Type: `string`
+
+### [resource\_group\_name](#input\_resource\_group\_name)
+
+Description: The name of the resource group in which to create the Bastion host.
+
+Type: `string`
+
+### [subnet\_id](#input\_subnet\_id)
+
+Description: The ID of the AzureBastionSubnet in which the Bastion host will be deployed.
+
+Type: `string`
+
+## Optional Inputs
+
+The following input variables are optional (have default values):
+
+### [copy\_paste\_enabled](#input\_copy\_paste\_enabled)
+
+Description: Is copy/paste feature enabled for the Bastion host.
+
+Type: `bool`
+
+Default: `true`
+
+### [file\_copy\_enabled](#input\_file\_copy\_enabled)
+
+Description: Is file copy feature enabled for the Bastion host. Requires Standard SKU or higher.
+
+Type: `bool`
+
+Default: `false`
+
+### [ip\_connect\_enabled](#input\_ip\_connect\_enabled)
+
+Description: Is IP connect feature enabled for the Bastion host. Requires Standard SKU or higher.
+
+Type: `bool`
+
+Default: `false`
+
+### [location](#input\_location)
+
+Description: The location/region where the Bastion host will be created.
+
+Type: `string`
+
+Default: `"uksouth"`
+
+### [scale\_units](#input\_scale\_units)
+
+Description: The number of scale units for the Bastion host. Each unit supports ~20 concurrent RDP / ~40 concurrent SSH sessions. Must be between 2 and 50 for Standard/Premium SKU; Basic is fixed at 2.
+
+Type: `number`
+
+Default: `2`
+
+### [shareable\_link\_enabled](#input\_shareable\_link\_enabled)
+
+Description: Is shareable link feature enabled for the Bastion host. Requires Standard SKU or higher.
+
+Type: `bool`
+
+Default: `false`
+
+### [sku](#input\_sku)
+
+Description: The SKU tier of the Bastion host. Possible values are Basic, Standard, and Premium. Standard is recommended for most production workloads; Premium adds session recording and private-only deployment. Developer SKU is not supported by this module as it does not use a public IP or dedicated subnet.
+
+Type: `string`
+
+Default: `"Standard"`
+
+### [tags](#input\_tags)
+
+Description: A mapping of tags to assign to the resource.
+
+Type: `map(string)`
+
+Default: `{}`
+
+### [tunneling\_enabled](#input\_tunneling\_enabled)
+
+Description: Is tunneling (native client support) feature enabled for the Bastion host. Enables native SSH/RDP client connections via az network bastion ssh/rdp. Recommended for Standard SKU or higher.
+
+Type: `bool`
+
+Default: `false`
+
+### [zones](#input\_zones)
+
+Description: Availability zones for the public IP address. Use ["1", "2", "3"] for zone-redundant deployment, which is recommended for production environments. An empty list deploys with no zone redundancy.
+
+Type: `list(string)`
+
+Default: `[]`
+## Modules
+
+The following Modules are called:
+
+### [diagnostic\_settings](#module\_diagnostic\_settings)
+
+Source: ../diagnostic-settings
+
+Version:
+
+### [pip](#module\_pip)
+
+Source: ../public-ip
+
+Version:
+## Outputs
+
+The following outputs are exported:
+
+### [dns\_name](#output\_dns\_name)
+
+Description: The FQDN of the Bastion host.
+
+### [id](#output\_id)
+
+Description: The ID of the Bastion host.
+
+### [name](#output\_name)
+
+Description: The name of the Bastion host.
+
+### [public\_ip\_address](#output\_public\_ip\_address)
+
+Description: The public IP address associated with the Bastion host.
+## Resources
+
+The following resources are used by this module:
+
+- [azurerm_bastion_host.bastion](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/bastion_host) (resource)
diff --git a/infrastructure/modules/bastion/variables.tf b/infrastructure/modules/bastion/variables.tf
new file mode 100644
index 00000000..d57f59a2
--- /dev/null
+++ b/infrastructure/modules/bastion/variables.tf
@@ -0,0 +1,114 @@
+variable "name" {
+ description = "The name of the Azure Bastion host."
+ type = string
+ validation {
+ condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9.-]{0,78}[a-zA-Z0-9]$", var.name))
+ error_message = "The Bastion host name must be between 2 and 80 characters, start and end with an alphanumeric character, and can contain alphanumeric characters, hyphens, and periods."
+ }
+}
+
+variable "resource_group_name" {
+ description = "The name of the resource group in which to create the Bastion host."
+ type = string
+}
+
+variable "location" {
+ description = "The location/region where the Bastion host will be created."
+ type = string
+ default = "uksouth"
+ validation {
+ condition = contains(["uksouth", "ukwest"], var.location)
+ error_message = "The location must be either uksouth or ukwest."
+ }
+}
+
+variable "sku" {
+ description = "The SKU tier of the Bastion host. Possible values are Basic, Standard, and Premium. Standard is recommended for most production workloads; Premium adds session recording and private-only deployment. Developer SKU is not supported by this module as it does not use a public IP or dedicated subnet."
+ type = string
+ default = "Standard"
+ validation {
+ condition = contains(["Basic", "Standard", "Premium"], var.sku)
+ error_message = "The SKU must be one of: Basic, Standard, Premium. Developer SKU is not supported by this module."
+ }
+}
+
+variable "subnet_id" {
+ description = "The ID of the AzureBastionSubnet in which the Bastion host will be deployed."
+ type = string
+}
+
+variable "public_ip_name" {
+ description = "The name of the public IP address resource created for the Bastion host."
+ type = string
+}
+
+variable "copy_paste_enabled" {
+ description = "Is copy/paste feature enabled for the Bastion host."
+ type = bool
+ default = true
+}
+
+variable "file_copy_enabled" {
+ description = "Is file copy feature enabled for the Bastion host. Requires Standard SKU or higher."
+ type = bool
+ default = false
+}
+
+variable "ip_connect_enabled" {
+ description = "Is IP connect feature enabled for the Bastion host. Requires Standard SKU or higher."
+ type = bool
+ default = false
+}
+
+variable "tunneling_enabled" {
+ description = "Is tunneling (native client support) feature enabled for the Bastion host. Enables native SSH/RDP client connections via az network bastion ssh/rdp. Recommended for Standard SKU or higher."
+ type = bool
+ default = false
+}
+
+variable "shareable_link_enabled" {
+ description = "Is shareable link feature enabled for the Bastion host. Requires Standard SKU or higher."
+ type = bool
+ default = false
+}
+
+variable "scale_units" {
+ description = "The number of scale units for the Bastion host. Each unit supports ~20 concurrent RDP / ~40 concurrent SSH sessions. Must be between 2 and 50 for Standard/Premium SKU; Basic is fixed at 2."
+ type = number
+ default = 2
+ validation {
+ condition = var.scale_units >= 2 && var.scale_units <= 50
+ error_message = "scale_units must be between 2 and 50."
+ }
+}
+
+variable "zones" {
+ description = "Availability zones for the public IP address. Use [\"1\", \"2\", \"3\"] for zone-redundant deployment, which is recommended for production environments. An empty list deploys with no zone redundancy."
+ type = list(string)
+ default = []
+ validation {
+ condition = length(var.zones) <= 3
+ error_message = "A maximum of 3 availability zones can be specified."
+ }
+}
+
+variable "log_analytics_workspace_id" {
+ description = "The ID of the Log Analytics workspace to send diagnostic logs to."
+ type = string
+}
+
+variable "monitor_diagnostic_setting_bastion_enabled_logs" {
+ description = "List of log categories to enable for the Bastion diagnostic setting (e.g. [\"BastionAuditLogs\"])."
+ type = list(string)
+}
+
+variable "monitor_diagnostic_setting_bastion_metrics" {
+ description = "List of metric categories to enable for the Bastion diagnostic setting (e.g. [\"AllMetrics\"])."
+ type = list(string)
+}
+
+variable "tags" {
+ description = "A mapping of tags to assign to the resource."
+ type = map(string)
+ default = {}
+}
diff --git a/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md b/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md
index eaaa13a0..4ea695b0 100644
--- a/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md
+++ b/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md
@@ -129,7 +129,7 @@ Type:
```hcl
map(object({
- associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain
+ associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain
cdn_frontdoor_firewall_policy_id = optional(string, null) # Pass ID directly to avoid data source lookup when policy is created in the same apply
cdn_frontdoor_firewall_policy_name = optional(string, null)
cdn_frontdoor_firewall_policy_rg_name = optional(string, null)
diff --git a/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf b/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf
index 67317fde..d49208bc 100644
--- a/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf
+++ b/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf
@@ -89,7 +89,7 @@ variable "route" {
variable "security_policies" {
description = "Optional map of security policies to apply. Each must include the WAF policy and domain associations"
type = map(object({
- associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain
+ associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain
cdn_frontdoor_firewall_policy_id = optional(string, null) # Pass ID directly to avoid data source lookup when policy is created in the same apply
cdn_frontdoor_firewall_policy_name = optional(string, null)
cdn_frontdoor_firewall_policy_rg_name = optional(string, null)