diff --git a/CHANGELOG b/CHANGELOG
index eb7a005822ec73c1a84097cd1eb648000bceb431..0b8bada35b8cf4a38490c7e4db6423cb9b12a781 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [4.1.0] - 2021-07-28
+### Added
+ - Support load balancer ingress style alongside Cloud Run domain mapping.
+
 ## [4.0.1] - 2021-07-15
 ### Changed
  - Surface Cloud NAT variable for minimum number of SNAT tuples, supporting a larger
diff --git a/README.md b/README.md
index 30cfb1c4b0e6ae2a019511ce7eaf0586e661931f..ce61e550e01fb8e5f70976a333d71ff31d7a43c3 100644
--- a/README.md
+++ b/README.md
@@ -24,14 +24,24 @@ The `master` branch contains the tip of development and corresponds to the `v2`
 branch. The `v1` branch will maintain source compatibility with the initial
 release.
 
-## Custom domain mapping
+## Ingress style
+There are two supported ingress styles depending on `var.ingress_style` variable.
 
-Setting the `dns_name` will create a domain mapping for the webapp. Before
-setting this value you *must* have verified ownership of the domain with Google.
-[Instructions on how to do
+`var.ingress_style` can be: 
+
+- `domain-mapping` (default): passing DNS domains as `var.dns_names` or `var.dns_name`,
+which takes precedence over `var.dns_names`, will create domain mappings to the Cloud
+Run service. Before setting this, you *must* have verified ownership of the provided
+domains with Google. [Instructions on how to do
 this](https://guidebook.devops.uis.cam.ac.uk/en/latest/notes/google-domain-verification/)
 can be found in the DevOps division guidebook.
 
+- `load-balancer`: a load balancer will be configured instead of a domain mapping. The
+DNS domains in `var.dns_names` or `var.dns_name`, which takes precedence over `var.dns_names`,
+will get Google-managed or custom TLS certificates depending on `var.use_ssl_certificates`
+and `var.ssl_certificates`. An IPv6 address can also be allocated to the load balancer if
+`var.enable_ipv6` is `true`.
+
 ## Monitoring and Alerting
 
 If the variable [alerting_email_address](variables.tf) is set, the module adds
diff --git a/load_balancer.tf b/load_balancer.tf
new file mode 100644
index 0000000000000000000000000000000000000000..ac0fc3506688e8b48a22e7af39c42560f05048b6
--- /dev/null
+++ b/load_balancer.tf
@@ -0,0 +1,71 @@
+# load_balancer.tf configures Cloud Load Balancer resources for the Cloud Run
+# service if var.ingress_style == "load-balancer".
+
+# A network endpoint group for the "webapp" application.
+resource "google_compute_region_network_endpoint_group" "webapp" {
+  count = var.ingress_style == "load-balancer" ? 1 : 0
+
+  name                  = var.name
+  network_endpoint_type = "SERVERLESS"
+  region                = var.cloud_run_region
+  cloud_run {
+    service = google_cloud_run_service.webapp.name
+  }
+
+  provider = google-beta
+}
+
+# A load balancer for the "webapp" application. This is just a set of sane
+# defaults. See the full documentation at [1] for customisation.
+#
+# [1] https://registry.terraform.io/modules/GoogleCloudPlatform/lb-http/google/latest/submodules/serverless_negs
+module "webapp_http_load_balancer" {
+  for_each = toset([for neg in google_compute_region_network_endpoint_group.webapp : neg.id])
+
+  # The double slash is important(!)
+  source  = "GoogleCloudPlatform/lb-http/google//modules/serverless_negs"
+  version = "~> 5.0"
+
+  project = var.project
+  name    = var.name
+
+  ssl            = true
+  https_redirect = true
+
+  # Use custom TLS certs if var.use_ssl_certificates is true, otherwise, use the Google-managed certs.
+  use_ssl_certificates            = var.use_ssl_certificates
+  ssl_certificates                = var.ssl_certificates
+  managed_ssl_certificate_domains = local.dns_names
+
+  # Whether to create an IPv6 address to the load balancer.
+  enable_ipv6         = var.enable_ipv6
+  create_ipv6_address = var.create_ipv6_address
+
+  backends = {
+    default = {
+      description            = null
+      enable_cdn             = false
+      custom_request_headers = null
+      security_policy        = null
+
+      log_config = {
+        enable      = true
+        sample_rate = 1.0
+      }
+
+      groups = [
+        {
+          group = each.key
+        }
+      ]
+
+      # Currently Cloud IAP is not supported for Cloud Run endpoints. We still
+      # need to specify that we don't want to use it though :).
+      iap_config = {
+        enable               = false
+        oauth2_client_id     = null
+        oauth2_client_secret = null
+      }
+    }
+  }
+}
diff --git a/locals.tf b/locals.tf
index 8b5966323b050e90dddcc52d0fabed6f7da34e02..ce0b188ba911aa7a69fb843aec59e06d4b73be2b 100644
--- a/locals.tf
+++ b/locals.tf
@@ -5,7 +5,39 @@ locals {
   sql_instance_project = coalesce(var.sql_instance_project, var.project)
 
   # Should a DNS domain mapping be created?
-  domain_mapping_present = var.dns_name != ""
+  domain_mapping_present = anytrue([for dm in google_cloud_run_domain_mapping.webapp : true])
+
+  # DNS names for web app
+  dns_names = var.dns_name != "" ? [var.dns_name] : var.dns_names
+
+  # DNS records for webapp. Merge records from any domain mappings or load balancers.
+  dns_records = flatten(concat(
+    [
+      for domain_mapping in google_cloud_run_domain_mapping.webapp : [
+        {
+          type   = domain_mapping.status[0].resource_records[0].type
+          rrdata = domain_mapping.status[0].resource_records[0].rrdata
+        }
+      ]
+    ],
+    [
+      for load_balancer in module.webapp_http_load_balancer : [
+        {
+          type   = "A"
+          rrdata = load_balancer.external_ip
+        },
+        {
+          type   = "AAAA"
+          rrdata = load_balancer.external_ipv6_address
+        }
+      ]
+    ]
+  ))
+
+  # Certain ingress styles imply that we disallow external access to the base Cloud Run service.
+  webapp_allowed_ingress = lookup({
+    load-balancer = "internal-and-cloud-load-balancing"
+  }, var.ingress_style, var.allowed_ingress)
 
   # Do we need to enable the 'beta' launch stage - only required if certain beta
   # functionality is being used, or if `enable_beta_launch_stage` is set downstream.
@@ -13,14 +45,10 @@ locals {
     var.enable_beta_launch_stage || length(var.secrets_volume) != 0 || length(var.secrets_envars) != 0
   )
 
-  # Whether we should monitor the custom domain - only possible if there is a dns_name
-  # set and unauthenticated invocation is enabled
-  can_monitor_custom_dns = var.dns_name != "" && var.allow_unauthenticated_invocations
-
   # Hosts to monitor. We use the automatic host from Cloud Run and any custom
-  # domain mapped host, if can_monitor_custom_dns is true
+  # domain mapped host.
   monitor_hosts = var.disable_monitoring ? [] : concat(
     [trimsuffix(trimprefix(google_cloud_run_service.webapp.status[0].url, "https://"), "/")],
-    local.can_monitor_custom_dns ? [var.dns_name] : []
+    var.allow_unauthenticated_invocations ? local.dns_names : [],
   )
 }
diff --git a/main.tf b/main.tf
index b8a3be531b2759d8a437d4d385962a4c2ca6ad6a..6c9246429d3e76f2e813932521bf5e4e375e74ed 100644
--- a/main.tf
+++ b/main.tf
@@ -51,7 +51,7 @@ resource "google_cloud_run_service" "webapp" {
 
       # Specify the allowable ingress types.
       {
-        "run.googleapis.com/ingress" : var.allowed_ingress,
+        "run.googleapis.com/ingress" : local.webapp_allowed_ingress,
       },
 
       var.service_annotations,
@@ -216,11 +216,11 @@ resource "google_cloud_run_service_iam_member" "webapp_all_users_invoker" {
 # the domain mapping if the domain is *not* verified because Google won't let
 # us.
 resource "google_cloud_run_domain_mapping" "webapp" {
-  count = local.domain_mapping_present ? 1 : 0
+  for_each = toset(var.ingress_style == "domain-mapping" ? local.dns_names : [])
 
   location = var.cloud_run_region
 
-  name = var.dns_name
+  name = each.key
 
   metadata {
     # For managed Cloud Run, the namespace *must* be the project name.
diff --git a/outputs.tf b/outputs.tf
index 29ba066cca000da7d3830825c5d29bcd915baaa7..3564fca04b345c3bcabc3b533c363611b473451f 100644
--- a/outputs.tf
+++ b/outputs.tf
@@ -16,15 +16,22 @@ output "domain_mapping_present" {
 }
 
 output "domain_mapping_resource_record" {
+  value       = try(local.dns_records[0], {})
   description = <<EOI
-Resource record for domain mapping. If a domain mapping is configured the
-following keys will be set: type and rrdata. If no mapping is configured, the
-map will be empty.
-EOI
-  value = local.domain_mapping_present ? {
-    type   = google_cloud_run_domain_mapping.webapp[0].status[0].resource_records[0].type
-    rrdata = google_cloud_run_domain_mapping.webapp[0].status[0].resource_records[0].rrdata
-  } : {}
+    Deprecated. Use dns_resource_records output instead.
+
+    Resource record for DNS hostnames. If a domain mapping or load balancing is configured
+    the following keys will be set: type and rrdata. If no mapping is configured, the
+    map will be empty.
+  EOI
+}
+
+output "dns_resource_records" {
+  value       = local.dns_records
+  description = <<EOI
+    List of DNS records for web application. Each element is an object with "type" and "rrdata"
+    keys.
+  EOI
 }
 
 output "domain_mapping_dns_name" {
diff --git a/variables.tf b/variables.tf
index 88935bbafb912b3ee7578e0b892ece0540447814..239f6293e77446ac6f42780f8c2ac28088da643f 100644
--- a/variables.tf
+++ b/variables.tf
@@ -73,17 +73,80 @@ EOI
   default     = true
 }
 
+variable "ingress_style" {
+  type        = string
+  default     = "domain-mapping"
+  description = "Whether to configure a load balancer or create a domain mapping"
+  validation {
+    condition     = contains(["domain-mapping", "load-balancer"], var.ingress_style)
+    error_message = "Ingress style must be one of 'domain-mapping' or 'load-balancer'."
+  }
+}
+
 variable "dns_name" {
+  default     = ""
   description = <<EOI
-If non-empty, a domain mapping will be created for the webapp from this domain
-to point to the webapp. The domain must first have been verified by Google and
-the account being used by the google provider must have been added as an owner.
+    Deprecated: use the dns_names variable instead.
 
-If and only if a domain mapping has been created, the
-"domain_mapping_resource_record" output will be a non-empty map and the
-"domain_mapping_present" output will be true.
-EOI
-  default     = ""
+    If non-empty, var.dns_names will be ignored.
+
+    If non-empty, a domain mapping will be created for the webapp from this host
+    to point to the webapp or a load balancer will be created for this host depending
+    on the value of the ingress_style variable.
+
+    The domain must first have been verified by Google and the account being used by
+    the google provider must have been added as an owner.
+
+    If and only if a domain mapping has been created, the
+    "domain_mapping_present" output will be true.
+
+    If a domain mapping or load balancer has been created, the "dns_resource_records"
+    output contains the appropriate DNS records.
+  EOI
+}
+
+variable "dns_names" {
+  type        = list(any)
+  default     = []
+  description = <<EOI
+    List of DNS names for web application. Note that no records are created,
+    the records to be created can be found in the dns_resource_records output.
+
+    Ignored if var.dns_name is non-empty.
+  EOI
+}
+
+variable "use_ssl_certificates" {
+  type    = bool
+  default = false
+
+  description = <<EOI
+    Whether to use the custom TLS certs in var.ssl_certificates for the load balancer
+    or the Google-managed certs for the specified var.dns_names.
+  EOI
+}
+
+variable "ssl_certificates" {
+  type    = list(any)
+  default = []
+
+  description = <<EOI
+    A list of self-links to any custom TLS certificates to add to the load balancer.
+    Requires that var.ingress_style be "load-balancer". The self-link is available as
+    the "self_link" attribute of "google_compute_ssl_certificate" resources.
+  EOI
+}
+
+variable "enable_ipv6" {
+  type        = bool
+  default     = false
+  description = "Whether to enable IPv6 address on the CDN load-balancer."
+}
+
+variable "create_ipv6_address" {
+  type        = bool
+  default     = false
+  description = "Allocate an IPv6 address to the load balancer if var.enable_ipv6 is true."
 }
 
 variable "service_account_id" {
@@ -153,6 +216,9 @@ variable "allowed_ingress" {
     Specify the allowed ingress to the service. Should be one of:
     "all", "internal" or "internal-and-cloud-load-balancing".
 
+    If var.ingress_style == "load-balancer", the provided var.allowed_ingress will be ignored
+    and the allowed ingress will be set automatically to "internal-and-cloud-load-balancing".
+
     Setting this to a value other than "all" implies that the service will be
     moved to the "beta" launch stage. See
     https://cloud.google.com/run/docs/troubleshooting#launch-stage-validation.
@@ -177,7 +243,6 @@ variable "template_annotations" {
   EOL
 }
 
-
 variable "enable_beta_launch_stage" {
   default     = false
   description = "Force use of the 'BETA' launch stage for the service."