diff --git a/modules/ncc-spoke-ra/README.md b/modules/ncc-spoke-ra/README.md
new file mode 100644
index 0000000000..58f5cd9103
--- /dev/null
+++ b/modules/ncc-spoke-ra/README.md
@@ -0,0 +1,152 @@
+# NCC Spoke RA Module
+
+This module allows management of NCC Spokes backed by Router Appliances. Network virtual appliances used as router appliances allow to connect an external network to Google Cloud by using a SD-WAN router or another appliance with BGP capabilities (_site-to-cloud_ connectivity). It is also possible to enable site-to-site data transfer, although this feature is not available in all regions, particularly not in EMEA.
+
+The module manages a hub (optionally), a spoke, and the corresponding Cloud Router and BGP sessions to the router appliance(s).
+
+## Examples
+
+### Simple hub & spoke
+
+```hcl
+module "spoke-ra" {
+ source = "./fabric/modules/ncc-spoke-ra"
+ hub = { create = true, name = "ncc-hub" }
+ name = "spoke-ra"
+ project_id = "my-project"
+ region = "europe-west1"
+ router_appliances = [
+ {
+ internal_ip = "10.0.0.3"
+ vm_self_link = "projects/my-project/zones/europe-west1-b/instances/router-app"
+ }
+ ]
+ router_config = {
+ asn = 65000
+ ip_interface1 = "10.0.0.14"
+ ip_interface2 = "10.0.0.15"
+ peer_asn = 65001
+ }
+ vpc_config = {
+ network_name = "my-vpc"
+ subnet_self_link = var.subnet.self_link
+ }
+}
+# tftest modules=1 resources=7
+```
+
+### Two spokes
+
+```hcl
+module "spoke-ra-a" {
+ source = "./fabric/modules/ncc-spoke-ra"
+ hub = { name = "ncc-hub" }
+ name = "spoke-ra-a"
+ project_id = "my-project"
+ region = "europe-west1"
+ router_appliances = [
+ {
+ internal_ip = "10.0.0.3"
+ vm_self_link = "projects/my-project/zones/europe-west1-b/instances/router-app-a"
+ }
+ ]
+ router_config = {
+ asn = 65000
+ ip_interface1 = "10.0.0.14"
+ ip_interface2 = "10.0.0.15"
+ peer_asn = 65001
+ }
+ vpc_config = {
+ network_name = "my-vpc1"
+ subnet_self_link = "projects/my-project/regions/europe-west1/subnetworks/subnet"
+ }
+}
+
+module "spoke-ra-b" {
+ source = "./fabric/modules/ncc-spoke-ra"
+ hub = { name = "ncc-hub" }
+ name = "spoke-ra-b"
+ project_id = "my-project"
+ region = "europe-west3"
+ router_appliances = [
+ {
+ internal_ip = "10.1.0.5"
+ vm_self_link = "projects/my-project/zones/europe-west3-b/instances/router-app-b"
+ }
+ ]
+ router_config = {
+ asn = 65000
+ ip_interface1 = "10.0.0.14"
+ ip_interface2 = "10.0.0.15"
+ peer_asn = 65002
+ }
+ vpc_config = {
+ network_name = "my-vpc2"
+ subnet_self_link = "projects/my-project/regions/europe-west3/subnetworks/subnet"
+ }
+}
+# tftest modules=2 resources=12
+```
+
+### Spoke with load-balanced router appliances
+
+```hcl
+module "spoke-ra" {
+ source = "./fabric/modules/ncc-spoke-ra"
+ hub = { name = "ncc-hub" }
+ name = "spoke-ra"
+ project_id = "my-project"
+ region = "europe-west1"
+ router_appliances = [
+ {
+ internal_ip = "10.0.0.3"
+ vm_self_link = "projects/my-project/zones/europe-west1-b/instances/router-app-a"
+ },
+ {
+ internal_ip = "10.0.0.4"
+ vm_self_link = "projects/my-project/zones/europe-west1-c/instances/router-app-b"
+ }
+ ]
+ router_config = {
+ asn = 65000
+ custom_advertise = {
+ all_subnets = true
+ ip_ranges = {
+ "10.10.0.0/24" = "peered-vpc"
+ }
+ }
+ ip_interface1 = "10.0.0.14"
+ ip_interface2 = "10.0.0.15"
+ peer_asn = 65001
+ }
+ vpc_config = {
+ network_name = "my-vpc"
+ subnet_self_link = var.subnet.self_link
+ }
+}
+# tftest modules=1 resources=8
+```
+
+
+## Variables
+
+| name | description | type | required | default |
+|---|---|:---:|:---:|:---:|
+| [hub](variables.tf#L23) | The name of the NCC hub to create or use. | object({…})
| ✓ | |
+| [name](variables.tf#L32) | The name of the NCC spoke. | string
| ✓ | |
+| [project_id](variables.tf#L37) | The ID of the project where the NCC hub & spokes will be created. | string
| ✓ | |
+| [region](variables.tf#L42) | Region where the spoke is located. | string
| ✓ | |
+| [router_appliances](variables.tf#L47) | List of router appliances this spoke is associated with. | list(object({…}))
| ✓ | |
+| [router_config](variables.tf#L55) | Configuration of the Cloud Router. | object({…})
| ✓ | |
+| [vpc_config](variables.tf#L70) | Network and subnetwork for the CR interfaces. | object({…})
| ✓ | |
+| [data_transfer](variables.tf#L17) | Site-to-site data transfer feature, available only in some regions. | bool
| | false
|
+
+## Outputs
+
+| name | description | sensitive |
+|---|---|:---:|
+| [hub](outputs.tf#L17) | NCC hub resource (only if auto-created). | |
+| [router](outputs.tf#L22) | Cloud Router resource. | |
+| [spoke-ra](outputs.tf#L27) | NCC spoke resource. | |
+
+
diff --git a/modules/ncc-spoke-ra/main.tf b/modules/ncc-spoke-ra/main.tf
new file mode 100644
index 0000000000..ab009dabbf
--- /dev/null
+++ b/modules/ncc-spoke-ra/main.tf
@@ -0,0 +1,123 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+locals {
+ spoke_vms = [
+ for ras in var.router_appliances : {
+ ip = ras.internal_ip
+ vm = ras.vm_self_link
+ vm_name = element(
+ split("/", ras.vm_self_link), length(split("/", ras.vm_self_link)) - 1
+ )
+ }
+ ]
+}
+
+resource "google_network_connectivity_hub" "hub" {
+ count = var.hub.create ? 1 : 0
+ project = var.project_id
+ name = var.hub.name
+ description = var.hub.description
+}
+
+resource "google_network_connectivity_spoke" "spoke-ra" {
+ project = var.project_id
+ hub = try(google_network_connectivity_hub.hub[0].name, var.hub.name)
+ location = var.region
+ name = var.name
+ linked_router_appliance_instances {
+ dynamic "instances" {
+ for_each = var.router_appliances
+ content {
+ ip_address = instances.value["internal_ip"]
+ virtual_machine = instances.value["vm_self_link"]
+ }
+ }
+ site_to_site_data_transfer = var.data_transfer
+ }
+}
+
+resource "google_compute_router" "cr" {
+ project = var.project_id
+ name = "${var.name}-cr"
+ network = var.vpc_config.network_name
+ region = var.region
+ bgp {
+ advertise_mode = (
+ var.router_config.custom_advertise != null ? "CUSTOM" : "DEFAULT"
+ )
+ advertised_groups = (
+ try(var.router_config.custom_advertise.all_subnets, false)
+ ? ["ALL_SUBNETS"] : []
+ )
+ dynamic "advertised_ip_ranges" {
+ for_each = try(var.router_config.custom_advertise.ip_ranges, {})
+ content {
+ description = advertised_ip_ranges.value
+ range = advertised_ip_ranges.key
+ }
+ }
+ asn = var.router_config.asn
+ keepalive_interval = try(var.router_config.keepalive, null)
+ }
+}
+
+resource "google_compute_router_interface" "intf1" {
+ project = var.project_id
+ name = "intf1"
+ router = google_compute_router.cr.name
+ region = var.region
+ subnetwork = var.vpc_config.subnet_self_link
+ private_ip_address = var.router_config.ip_interface1
+}
+
+resource "google_compute_router_interface" "intf2" {
+ project = var.project_id
+ name = "intf2"
+ router = google_compute_router.cr.name
+ region = var.region
+ subnetwork = var.vpc_config.subnet_self_link
+ private_ip_address = var.router_config.ip_interface2
+ redundant_interface = google_compute_router_interface.intf1.name
+}
+
+resource "google_compute_router_peer" "peer1" {
+ for_each = {
+ for idx, entry in local.spoke_vms : idx => entry
+ }
+ project = var.project_id
+ name = "peer1-${each.value.vm_name}"
+ router = google_compute_router.cr.name
+ region = var.region
+ interface = google_compute_router_interface.intf1.name
+ peer_asn = var.router_config.peer_asn
+ peer_ip_address = each.value.ip
+ router_appliance_instance = each.value.vm
+}
+
+resource "google_compute_router_peer" "peer2" {
+ for_each = {
+ for idx, entry in local.spoke_vms : idx => entry
+ }
+ project = var.project_id
+ name = "peer2-${each.value.vm_name}"
+ router = google_compute_router.cr.name
+ region = var.region
+ interface = google_compute_router_interface.intf2.name
+ peer_asn = var.router_config.peer_asn
+ peer_ip_address = each.value.ip
+ router_appliance_instance = each.value.vm
+}
diff --git a/modules/ncc-spoke-ra/outputs.tf b/modules/ncc-spoke-ra/outputs.tf
new file mode 100644
index 0000000000..d1bfdb36d8
--- /dev/null
+++ b/modules/ncc-spoke-ra/outputs.tf
@@ -0,0 +1,30 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+output "hub" {
+ description = "NCC hub resource (only if auto-created)."
+ value = one(google_network_connectivity_hub.hub[*])
+}
+
+output "router" {
+ description = "Cloud Router resource."
+ value = google_compute_router.cr
+}
+
+output "spoke-ra" {
+ description = "NCC spoke resource."
+ value = google_network_connectivity_spoke.spoke-ra
+}
diff --git a/modules/ncc-spoke-ra/variables.tf b/modules/ncc-spoke-ra/variables.tf
new file mode 100644
index 0000000000..1e3a6df38b
--- /dev/null
+++ b/modules/ncc-spoke-ra/variables.tf
@@ -0,0 +1,76 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+variable "data_transfer" {
+ description = "Site-to-site data transfer feature, available only in some regions."
+ type = bool
+ default = false
+}
+
+variable "hub" {
+ description = "The name of the NCC hub to create or use."
+ type = object({
+ create = optional(bool, false)
+ description = optional(string)
+ name = string
+ })
+}
+
+variable "name" {
+ description = "The name of the NCC spoke."
+ type = string
+}
+
+variable "project_id" {
+ description = "The ID of the project where the NCC hub & spokes will be created."
+ type = string
+}
+
+variable "region" {
+ description = "Region where the spoke is located."
+ type = string
+}
+
+variable "router_appliances" {
+ description = "List of router appliances this spoke is associated with."
+ type = list(object({
+ internal_ip = string
+ vm_self_link = string
+ }))
+}
+
+variable "router_config" {
+ description = "Configuration of the Cloud Router."
+ type = object({
+ asn = number
+ custom_advertise = optional(object({
+ all_subnets = bool
+ ip_ranges = map(string)
+ }))
+ ip_interface1 = string
+ ip_interface2 = string
+ keepalive = optional(number)
+ peer_asn = number
+ })
+}
+
+variable "vpc_config" {
+ description = "Network and subnetwork for the CR interfaces."
+ type = object({
+ network_name = string
+ subnet_self_link = string
+ })
+}