/execute-command. | `string` | `null` | no |
-| [cloudwatch\_log\_group\_retention\_in\_days](#input\_cloudwatch\_log\_group\_retention\_in\_days) | CloudWatch Log Group retention period in days for ECS Exec logs. | `number` | `90` | no |
+| [cloud\_watch\_encryption\_enabled](#input\_cloud\_watch\_encryption\_enabled) | Whether to enable encryption for ECS Exec logs stored in CloudWatch Logs. Encryption is mandatory when using CloudWatch destination. | `bool` | `true` | no |
+| [cloudwatch\_log\_group\_name](#input\_cloudwatch\_log\_group\_name) | Optional name of a pre-created CloudWatch Log Group for ECS Exec session logs.
The log group must be created separately via the cloudwatch-logs module.
Either cloudwatch\_log\_group\_name or s3\_bucket\_name must be provided
if enable\_execute\_command is true.
Example: /bcss/app/prod/ecs/cluster-exec-logs | `string` | `null` | no |
| [cluster\_capacity\_providers](#input\_cluster\_capacity\_providers) | Capacity provider names to associate with the ECS cluster. | `list(string)` | [
"FARGATE"
]
| no |
| [cluster\_name](#input\_cluster\_name) | Optional ECS cluster name. When null, this module uses module.this.id. | `string` | `null` | no |
| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` | {
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
} | no |
@@ -131,7 +320,7 @@ No resources.
| [enable\_execute\_command](#input\_enable\_execute\_command) | Whether to enable ECS Exec configuration at cluster level. | `bool` | `true` | no |
| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no |
| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no |
-| [execute\_command\_kms\_key\_id](#input\_execute\_command\_kms\_key\_id) | Optional KMS key ARN or ID for ECS Exec session data encryption. | `string` | `null` | no |
+| [execute\_command\_kms\_key\_id](#input\_execute\_command\_kms\_key\_id) | KMS key ARN or ID for encrypting ECS Exec session data. Required if enable\_execute\_command is true. | `string` | `null` | no |
| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no |
| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no |
| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no |
@@ -144,6 +333,10 @@ No resources.
| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no |
| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no |
| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no |
+| [s3\_bucket\_encryption\_enabled](#input\_s3\_bucket\_encryption\_enabled) | Whether to enforce encryption on the S3 bucket used for ECS Exec session logs.
If s3\_bucket\_name is provided, this must be set to true.
Encryption is mandatory for ECS Exec session data. | `bool` | `null` | no |
+| [s3\_bucket\_name](#input\_s3\_bucket\_name) | Optional S3 bucket name for ECS Exec session logs.
Either cloudwatch\_log\_group\_name or s3\_bucket\_name must be provided
if enable\_execute\_command is true. If specified, s3\_bucket\_encryption\_enabled
must be set to true (encryption is mandatory).
Example: bcss-prod-ecs-exec-logs | `string` | `null` | no |
+| [s3\_key\_prefix](#input\_s3\_key\_prefix) | Optional S3 key prefix for storing ECS Exec session logs.
All session logs will be stored under this prefix in the S3 bucket.
If not provided, logs are stored at the bucket root.
Example: ecs-exec-logs/ or prod/ecs-exec/ | `string` | `null` | no |
+| [s3\_kms\_key\_id](#input\_s3\_kms\_key\_id) | Optional KMS key ARN or ID to use for encrypting ECS Exec session logs in S3.
Only relevant if s3\_bucket\_name is provided and s3\_bucket\_encryption\_enabled is true.
Example: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 | `string` | `null` | no |
| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no |
| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no |
| [service\_connect\_defaults](#input\_service\_connect\_defaults) | Optional default Service Connect namespace configuration. | object({
namespace = string
}) | `null` | no |
diff --git a/infrastructure/modules/ecs-cluster/locals.tf b/infrastructure/modules/ecs-cluster/locals.tf
index 0f413811..33862ff1 100644
--- a/infrastructure/modules/ecs-cluster/locals.tf
+++ b/infrastructure/modules/ecs-cluster/locals.tf
@@ -2,8 +2,7 @@ locals {
# Derive the cluster name from context when no explicit override is provided.
cluster_name = var.cluster_name != null ? var.cluster_name : module.this.id
- cloudwatch_log_group_name = var.cloudwatch_log_group_name != null ? var.cloudwatch_log_group_name : "/aws/ecs/${local.cluster_name}/execute-command"
-
+ # Container Insights setting, enabled by default for observability
cluster_settings = var.enable_container_insights ? [
{
name = "containerInsights"
@@ -11,14 +10,27 @@ locals {
}
] : []
+ # ECS Exec log_configuration block (nested within execute_command_configuration)
+ # Maps to: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster#log_configuration
+ log_configuration = var.enable_execute_command ? {
+ cloud_watch_encryption_enabled = var.cloud_watch_encryption_enabled
+ cloud_watch_log_group_name = var.cloudwatch_log_group_name
+ s3_bucket_encryption_enabled = var.s3_bucket_encryption_enabled
+ s3_bucket_name = var.s3_bucket_name
+ s3_kms_key_id = var.s3_kms_key_id
+ s3_key_prefix = var.s3_key_prefix
+ } : null
+
+ # ECS Exec execute_command_configuration block
+ # Maps to: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster#execute_command_configuration
+ execute_command_configuration = var.enable_execute_command ? {
+ kms_key_id = var.execute_command_kms_key_id
+ log_configuration = local.log_configuration
+ logging = "OVERRIDE"
+ } : null
+
+ # Cluster-level configuration block for upstream module
cluster_configuration = var.enable_execute_command ? {
- execute_command_configuration = {
- kms_key_id = var.execute_command_kms_key_id
- logging = "OVERRIDE"
- log_configuration = {
- cloud_watch_encryption_enabled = var.cloudwatch_log_group_kms_key_id != null
- cloud_watch_log_group_name = local.cloudwatch_log_group_name
- }
- }
+ execute_command_configuration = local.execute_command_configuration
} : null
}
diff --git a/infrastructure/modules/ecs-cluster/main.tf b/infrastructure/modules/ecs-cluster/main.tf
index 752e1c15..974e21d7 100644
--- a/infrastructure/modules/ecs-cluster/main.tf
+++ b/infrastructure/modules/ecs-cluster/main.tf
@@ -7,7 +7,11 @@
# * Creation is gated by module.this.enabled
# * Naming and tagging are enforced via context.tf
# * CloudWatch Container Insights enabled by default
-# * ECS Exec logging uses an explicit CloudWatch Log Group
+# * ECS Exec logging supports two destination options (KMS encrypted):
+# - CloudWatch Logs: Log group created separately via cloudwatch-logs module
+# - S3: Bucket with encryption enabled (managed by caller)
+# * At least one log destination REQUIRED if ECS Exec enabled
+# * Cross-variable validation rules enforced in validations.tf
#
# Naming and tagging are derived from context.tf via module.this.
################################################################
@@ -23,19 +27,20 @@ module "ecs_cluster" {
configuration = local.cluster_configuration
- # The CloudWatch Log Group is created only when ECS Exec is enabled.
- # Consider moving the CloudWatch Log Group creation to a separate module
- # so that it can be created independently of the ECS cluster.
- create_cloudwatch_log_group = var.enable_execute_command
- cloudwatch_log_group_name = local.cloudwatch_log_group_name
- cloudwatch_log_group_kms_key_id = var.cloudwatch_log_group_kms_key_id
- cloudwatch_log_group_retention_in_days = var.cloudwatch_log_group_retention_in_days
- cloudwatch_log_group_class = var.cloudwatch_log_group_class
+ # CloudWatch Log Group is optional (if using CloudWatch for ECS Exec logs).
+ # When provided, it MUST be pre-created via the cloudwatch-logs module.
+ # Do not create it here; only reference the pre-created log group.
+ # Encryption (kms_key_id) is mandatory for security compliance.
+ # S3 logging is supported as an alternative. See validations.tf for constraints.
+ create_cloudwatch_log_group = false
cluster_capacity_providers = var.cluster_capacity_providers
default_capacity_provider_strategy = var.default_capacity_provider_strategy
service_connect_defaults = var.service_connect_defaults
+ # Container Insights setting is configured in local.cluster_settings
+ # based on var.enable_container_insights; no need to set it again here.
+
# Keep the module focused on the cluster itself; callers should
# manage IAM/security group resources separately in dedicated modules.
create_security_group = false
diff --git a/infrastructure/modules/ecs-cluster/validations.tf b/infrastructure/modules/ecs-cluster/validations.tf
new file mode 100644
index 00000000..c7b0bdfd
--- /dev/null
+++ b/infrastructure/modules/ecs-cluster/validations.tf
@@ -0,0 +1,45 @@
+################################################################
+# Cross-variable validation constraints for ECS cluster.
+#
+# These preconditions enforce:
+# * When ECS Exec is enabled:
+# - At least one log destination (CloudWatch and/or S3) required
+# - Session encryption (execute_command_kms_key_id) always required
+# * If CloudWatch destination: cloud_watch_encryption_enabled must be true
+# * If S3 destination: s3_bucket_encryption_enabled must be true, s3_kms_key_id required
+# * Both CloudWatch and S3 can be enabled simultaneously for redundancy
+################################################################
+
+resource "terraform_data" "validations" {
+ count = module.this.enabled ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = !var.enable_execute_command || (var.execute_command_kms_key_id != null && var.execute_command_kms_key_id != "")
+ error_message = "When enable_execute_command = true, execute_command_kms_key_id is REQUIRED. ECS Exec session data must be encrypted. Provide a KMS key ARN or ID for session encryption."
+ }
+
+ precondition {
+ condition = !var.enable_execute_command || (
+ (var.cloudwatch_log_group_name != null && var.cloudwatch_log_group_name != "") ||
+ (var.s3_bucket_name != null && var.s3_bucket_name != "")
+ )
+ error_message = "When enable_execute_command = true, at least one log destination is REQUIRED: cloudwatch_log_group_name and/or s3_bucket_name. Both can be configured simultaneously for redundancy."
+ }
+
+ precondition {
+ condition = var.cloudwatch_log_group_name == null || var.cloud_watch_encryption_enabled == true
+ error_message = "When cloudwatch_log_group_name is provided, cloud_watch_encryption_enabled must be true. CloudWatch log encryption is mandatory for ECS Exec sessions."
+ }
+
+ precondition {
+ condition = var.s3_bucket_name == null || var.s3_bucket_name == "" || var.s3_bucket_encryption_enabled == true
+ error_message = "When s3_bucket_name is provided, s3_bucket_encryption_enabled must be set to true. S3 encryption is mandatory for ECS Exec session logs."
+ }
+
+ precondition {
+ condition = !var.s3_bucket_encryption_enabled || (var.s3_kms_key_id != null && var.s3_kms_key_id != "")
+ error_message = "When s3_bucket_encryption_enabled = true, s3_kms_key_id is REQUIRED. Provide a KMS key ARN or ID for S3 encryption of ECS Exec session logs."
+ }
+ }
+}
diff --git a/infrastructure/modules/ecs-cluster/variables.tf b/infrastructure/modules/ecs-cluster/variables.tf
index 9673c3c3..81f46c56 100644
--- a/infrastructure/modules/ecs-cluster/variables.tf
+++ b/infrastructure/modules/ecs-cluster/variables.tf
@@ -29,43 +29,74 @@ variable "enable_execute_command" {
}
variable "execute_command_kms_key_id" {
- description = "Optional KMS key ARN or ID for ECS Exec session data encryption."
+ description = "KMS key ARN or ID for encrypting ECS Exec session data. Required if enable_execute_command is true."
type = string
default = null
}
+variable "cloud_watch_encryption_enabled" {
+ description = "Whether to enable encryption for ECS Exec logs stored in CloudWatch Logs. Encryption is mandatory when using CloudWatch destination."
+ type = bool
+ default = true
+}
+
variable "cloudwatch_log_group_name" {
- description = "Optional CloudWatch Log Group name for ECS Exec logs. Defaults to /aws/ecs//execute-command."
+ description = <<-EOT
+ Optional name of a pre-created CloudWatch Log Group for ECS Exec session logs.
+ The log group must be created separately via the cloudwatch-logs module.
+ Either cloudwatch_log_group_name or s3_bucket_name must be provided
+ if enable_execute_command is true.
+ Example: /bcss/app/prod/ecs/cluster-exec-logs
+ EOT
type = string
default = null
}
-variable "cloudwatch_log_group_kms_key_id" {
- description = "Optional KMS key ARN used to encrypt the CloudWatch Log Group."
+################################################################
+# ECS Exec S3 logging
+################################################################
+
+variable "s3_bucket_name" {
+ description = <<-EOT
+ Optional S3 bucket name for ECS Exec session logs.
+ Either cloudwatch_log_group_name or s3_bucket_name must be provided
+ if enable_execute_command is true. If specified, s3_bucket_encryption_enabled
+ must be set to true (encryption is mandatory).
+ Example: bcss-prod-ecs-exec-logs
+ EOT
type = string
default = null
}
-variable "cloudwatch_log_group_retention_in_days" {
- description = "CloudWatch Log Group retention period in days for ECS Exec logs."
- type = number
- default = 90
-
- validation {
- condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.cloudwatch_log_group_retention_in_days)
- error_message = "cloudwatch_log_group_retention_in_days must be a valid CloudWatch retention value."
- }
+variable "s3_bucket_encryption_enabled" {
+ description = <<-EOT
+ Whether to enforce encryption on the S3 bucket used for ECS Exec session logs.
+ If s3_bucket_name is provided, this must be set to true.
+ Encryption is mandatory for ECS Exec session data.
+ EOT
+ type = bool
+ default = null
}
-variable "cloudwatch_log_group_class" {
- description = "Optional CloudWatch Log Group class. Valid values are STANDARD or INFREQUENT_ACCESS."
+variable "s3_kms_key_id" {
+ description = <<-EOT
+ Optional KMS key ARN or ID to use for encrypting ECS Exec session logs in S3.
+ Only relevant if s3_bucket_name is provided and s3_bucket_encryption_enabled is true.
+ Example: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012
+ EOT
type = string
default = null
+}
- validation {
- condition = var.cloudwatch_log_group_class == null ? true : contains(["STANDARD", "INFREQUENT_ACCESS"], var.cloudwatch_log_group_class)
- error_message = "cloudwatch_log_group_class must be STANDARD, INFREQUENT_ACCESS, or null."
- }
+variable "s3_key_prefix" {
+ description = <<-EOT
+ Optional S3 key prefix for storing ECS Exec session logs.
+ All session logs will be stored under this prefix in the S3 bucket.
+ If not provided, logs are stored at the bucket root.
+ Example: ecs-exec-logs/ or prod/ecs-exec/
+ EOT
+ type = string
+ default = null
}
variable "cluster_capacity_providers" {
diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md
index afc52e7c..98627006 100644
--- a/infrastructure/modules/ecs-service/README.md
+++ b/infrastructure/modules/ecs-service/README.md
@@ -10,6 +10,7 @@ the shared `context.tf` for naming and tagging.
|Control|How it is enforced|
|---|---|
|No public IP|`assign_public_ip` defaults to `false`; tasks run on private subnets|
+|Security groups|Caller must provide security group IDs via `security_group_ids`; `create_security_group = false` prevents default SG creation|
|ECS-managed tags|`enable_ecs_managed_tags` defaults to `true`; AWS propagates resource tags to tasks|
|Tag propagation|`propagate_tags` defaults to `TASK_DEFINITION` so tasks inherit service tags|
|Creation gate|`create = module.this.enabled`; no resources are created when the module is disabled|
diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf
index b3058d23..09518c98 100644
--- a/infrastructure/modules/ecs-service/main.tf
+++ b/infrastructure/modules/ecs-service/main.tf
@@ -36,6 +36,7 @@ module "ecs_service" {
cpu = var.cpu
create_iam_role = var.create_iam_role
create_infrastructure_iam_role = var.create_infrastructure_iam_role
+ create_security_group = false
create_service = var.create_service
create_task_definition = var.create_task_definition
create_task_exec_iam_role = var.create_task_exec_iam_role
diff --git a/infrastructure/modules/efs/README.md b/infrastructure/modules/efs/README.md
index f0eaa86e..a89581d0 100644
--- a/infrastructure/modules/efs/README.md
+++ b/infrastructure/modules/efs/README.md
@@ -11,7 +11,7 @@ the shared `context.tf` for naming and tagging.
|---|---|
|Encryption at rest|KMS encryption is mandatory; `var.kms_key_arn` is required|
|Encryption in transit|TLS is always required; non-secure transport is denied by default|
-|Security groups|Mount targets require explicit security groups from the caller; no defaults|
+|Security groups|Mount targets require explicit security groups from the caller; `create_security_group = false` prevents default SG creation|
|Destructive operations|Delete operations are denied by default; must be explicitly allowed via policy|
|Tagging|All EFS resources tagged via `module.this.tags`|
|Creation gate|Resource creation gated by `module.this.enabled`|
diff --git a/infrastructure/modules/efs/main.tf b/infrastructure/modules/efs/main.tf
index 42282774..82be809e 100644
--- a/infrastructure/modules/efs/main.tf
+++ b/infrastructure/modules/efs/main.tf
@@ -48,7 +48,8 @@ module "efs" {
# ----------------------------------------------------------------
# Mount targets: caller must provide security group(s) and subnets.
# ----------------------------------------------------------------
- mount_targets = var.mount_targets
+ mount_targets = var.mount_targets
+ create_security_group = false
# ----------------------------------------------------------------
# Backup policy (optional).
diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md
index 11e589ef..ded4b9ad 100644
--- a/infrastructure/modules/rds/README.md
+++ b/infrastructure/modules/rds/README.md
@@ -158,7 +158,7 @@ This example shows the recommended pattern for production use: integrating with
```hcl
# Create a security group using the NHS security-group wrapper module
module "rds_security_group" {
- source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z""
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z"
service = "bcss"
project = "screening"
@@ -289,7 +289,9 @@ When migrating from `../../modules/rds` in the bcss repo to this shared module,
## Providers
-No providers.
+| Name | Version |
+| ---- | ------- |
+| [terraform](#provider\_terraform) | n/a |
## Modules
@@ -300,7 +302,9 @@ No providers.
## Resources
-No resources.
+| Name | Type |
+| ---- | ---- |
+| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
## Inputs
@@ -315,7 +319,10 @@ No resources.
| [backup\_retention\_period](#input\_backup\_retention\_period) | Number of days to retain automated backups. Must be between 0 and 35 | `number` | `7` | no |
| [backup\_window](#input\_backup\_window) | Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance\_window | `string` | `"23:00-23:30"` | no |
| [character\_set\_name](#input\_character\_set\_name) | Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot | `string` | `null` | no |
+| [cloudwatch\_log\_group\_kms\_key\_id](#input\_cloudwatch\_log\_group\_kms\_key\_id) | ARN of the customer-managed KMS key to encrypt CloudWatch log groups. Required when create\_cloudwatch\_log\_group is true and enabled\_cloudwatch\_logs\_exports is non-empty. AWS-managed keys are not acceptable per platform policy. Not used when create\_cloudwatch\_log\_group is false. | `string` | `null` | no |
+| [cloudwatch\_log\_group\_retention\_in\_days](#input\_cloudwatch\_log\_group\_retention\_in\_days) | Number of days to retain CloudWatch log groups created for RDS log exports. | `number` | `30` | no |
| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` | {
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
} | no |
+| [create\_cloudwatch\_log\_group](#input\_create\_cloudwatch\_log\_group) | When true (default), the module creates one CloudWatch log group per log type exported.
Log groups are named: /aws/rds/instance//
Set to false when the log groups are pre-created at consumer level — for example,
when you need a specific resource policy, cross-account sharing, or to have the
log group managed by a central logging stack. The log groups must exist before the
RDS instance is created and must follow the naming convention above.
When false, cloudwatch\_log\_group\_kms\_key\_id is not required by this module
(encryption is the caller's responsibility on the pre-existing log groups). | `bool` | `true` | no |
| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no |
| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no |
| [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no |
@@ -323,6 +330,7 @@ No resources.
| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no |
| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no |
| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no |
+| [enabled\_cloudwatch\_logs\_exports](#input\_enabled\_cloudwatch\_logs\_exports) | List of log types to export to CloudWatch Logs. Valid values by engine: postgres = ['postgresql', 'upgrade'], mysql = ['audit', 'error', 'general', 'slowquery'], oracle = ['alert', 'audit', 'listener', 'oemagent', 'trace'], sqlserver = ['agent', 'error']. Leave empty to disable. | `list(string)` | `[]` | no |
| [engine](#input\_engine) | The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql') | `string` | n/a | yes |
| [engine\_version](#input\_engine\_version) | The engine version to use | `string` | n/a | yes |
| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no |
@@ -340,6 +348,7 @@ No resources.
| [maintenance\_window](#input\_maintenance\_window) | Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00') | `string` | `"Sun:00:00-Sun:03:00"` | no |
| [major\_engine\_version](#input\_major\_engine\_version) | Major engine version for the option group (e.g. '19' for Oracle 19c) | `string` | n/a | yes |
| [manage\_master\_user\_password](#input\_manage\_master\_user\_password) | When true, RDS manages the master password in Secrets Manager. When false, password\_wo must be provided | `bool` | `false` | no |
+| [master\_user\_secret\_kms\_key\_id](#input\_master\_user\_secret\_kms\_key\_id) | The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. Required when manage\_master\_user\_password is true. AWS-managed keys are not acceptable per platform policy. | `string` | `null` | no |
| [max\_allocated\_storage](#input\_max\_allocated\_storage) | Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling | `number` | `0` | no |
| [monitoring\_interval](#input\_monitoring\_interval) | Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable | `number` | `5` | no |
| [multi\_az](#input\_multi\_az) | Specifies if the RDS instance is Multi-AZ | `bool` | `false` | no |
@@ -378,6 +387,7 @@ No resources.
| Name | Description |
| ---- | ----------- |
+| [cloudwatch\_log\_group\_arns](#output\_cloudwatch\_log\_group\_arns) | Map of CloudWatch log group names to ARNs, keyed by log type. Empty when enabled\_cloudwatch\_logs\_exports is not set. |
| [db\_option\_group\_id](#output\_db\_option\_group\_id) | ID of the DB option group |
| [db\_parameter\_group\_id](#output\_db\_parameter\_group\_id) | ID of the DB parameter group |
| [db\_subnet\_group\_id](#output\_db\_subnet\_group\_id) | Name/ID of the DB subnet group |
diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf
index 30efc083..fd44ebc6 100644
--- a/infrastructure/modules/rds/main.tf
+++ b/infrastructure/modules/rds/main.tf
@@ -9,6 +9,8 @@
# - copy_tags_to_snapshot = true (snapshots must carry the same tags)
# - auto_minor_version_upgrade = false (teams keep instances in sync with prod)
# - create_db_subnet_group = true (subnet group always managed by this module)
+#
+# Cross-variable input constraints are enforced in validations.tf.
# ----------------------------------------------------------------------------
module "rds" {
@@ -43,9 +45,10 @@ module "rds" {
port = var.port
# Credentials
- manage_master_user_password = var.manage_master_user_password
- password_wo = var.password_wo
- password_wo_version = var.password_wo_version
+ manage_master_user_password = var.manage_master_user_password
+ master_user_secret_kms_key_id = var.master_user_secret_kms_key_id
+ password_wo = var.password_wo
+ password_wo_version = var.password_wo_version
# Networking
publicly_accessible = false
@@ -66,6 +69,12 @@ module "rds" {
monitoring_interval = var.monitoring_interval
create_monitoring_role = module.this.enabled && var.monitoring_interval > 0
+ # CloudWatch log exports
+ enabled_cloudwatch_logs_exports = var.enabled_cloudwatch_logs_exports
+ create_cloudwatch_log_group = var.create_cloudwatch_log_group
+ cloudwatch_log_group_retention_in_days = var.cloudwatch_log_group_retention_in_days
+ cloudwatch_log_group_kms_key_id = var.cloudwatch_log_group_kms_key_id
+
# Availability and backup
multi_az = var.multi_az
backup_retention_period = var.backup_retention_period
diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf
index 7d642c75..680c075d 100644
--- a/infrastructure/modules/rds/outputs.tf
+++ b/infrastructure/modules/rds/outputs.tf
@@ -68,3 +68,10 @@ output "enhanced_monitoring_iam_role_arn" {
description = "ARN of the Enhanced Monitoring IAM role. Empty when monitoring_interval is 0"
value = module.rds.enhanced_monitoring_iam_role_arn
}
+
+# CloudWatch log groups
+
+output "cloudwatch_log_group_arns" {
+ description = "Map of CloudWatch log group names to ARNs, keyed by log type. Empty when enabled_cloudwatch_logs_exports is not set."
+ value = try(module.rds.db_instance_cloudwatch_log_groups, {})
+}
diff --git a/infrastructure/modules/rds/validations.tf b/infrastructure/modules/rds/validations.tf
new file mode 100644
index 00000000..914780c8
--- /dev/null
+++ b/infrastructure/modules/rds/validations.tf
@@ -0,0 +1,31 @@
+################################################################
+# Input validation
+#
+# Validates cross-variable constraints that cannot be expressed
+# through individual variable validation blocks:
+#
+# * storage_type 'io1' or 'io2' requires iops to be set
+# * manage_master_user_password = true requires master_user_secret_kms_key_id
+# * module-managed log groups require cloudwatch_log_group_kms_key_id (when create_cloudwatch_log_group = true)
+################################################################
+
+resource "terraform_data" "validations" {
+ count = module.this.enabled ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = !(contains(["io1", "io2"], coalesce(var.storage_type, "")) && var.iops == null)
+ error_message = "iops must be set when storage_type is 'io1' or 'io2'."
+ }
+
+ precondition {
+ condition = !var.manage_master_user_password || var.master_user_secret_kms_key_id != null
+ error_message = "master_user_secret_kms_key_id must be set when manage_master_user_password is true. AWS-managed keys are not acceptable per platform policy."
+ }
+
+ precondition {
+ condition = !(var.create_cloudwatch_log_group && length(var.enabled_cloudwatch_logs_exports) > 0 && var.cloudwatch_log_group_kms_key_id == null)
+ error_message = "cloudwatch_log_group_kms_key_id must be set when the module creates CloudWatch log groups (create_cloudwatch_log_group = true). AWS-managed keys are not acceptable per platform policy."
+ }
+ }
+}
diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf
index 7649da94..2735002e 100644
--- a/infrastructure/modules/rds/variables.tf
+++ b/infrastructure/modules/rds/variables.tf
@@ -116,6 +116,12 @@ variable "manage_master_user_password" {
default = false
}
+variable "master_user_secret_kms_key_id" {
+ description = "The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. Required when manage_master_user_password is true. AWS-managed keys are not acceptable per platform policy."
+ type = string
+ default = null
+}
+
# ----------------------------------------------------------------------------
# Database
# ----------------------------------------------------------------------------
@@ -199,18 +205,33 @@ variable "backup_retention_period" {
description = "Number of days to retain automated backups. Must be between 0 and 35"
type = number
default = 7
+
+ validation {
+ condition = var.backup_retention_period >= 0 && var.backup_retention_period <= 35
+ error_message = "backup_retention_period must be between 0 and 35."
+ }
}
variable "backup_window" {
description = "Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance_window"
type = string
default = "23:00-23:30"
+
+ validation {
+ condition = can(regex("^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$", var.backup_window))
+ error_message = "backup_window must be in HH:MM-HH:MM format (e.g. '23:00-23:30')."
+ }
}
variable "maintenance_window" {
description = "Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00')"
type = string
default = "Sun:00:00-Sun:03:00"
+
+ validation {
+ condition = can(regex("^(Mon|Tue|Wed|Thu|Fri|Sat|Sun):[0-9]{2}:[0-9]{2}-(Mon|Tue|Wed|Thu|Fri|Sat|Sun):[0-9]{2}:[0-9]{2}$", var.maintenance_window))
+ error_message = "maintenance_window must be in Ddd:HH:MM-Ddd:HH:MM format (e.g. 'Sun:00:00-Sun:03:00')."
+ }
}
variable "skip_final_snapshot" {
@@ -300,3 +321,42 @@ variable "timeouts" {
})
default = null
}
+
+# ----------------------------------------------------------------------------
+# CloudWatch log exports
+# ----------------------------------------------------------------------------
+
+variable "enabled_cloudwatch_logs_exports" {
+ description = "List of log types to export to CloudWatch Logs. Valid values by engine: postgres = ['postgresql', 'upgrade'], mysql = ['audit', 'error', 'general', 'slowquery'], oracle = ['alert', 'audit', 'listener', 'oemagent', 'trace'], sqlserver = ['agent', 'error']. Leave empty to disable."
+ type = list(string)
+ default = []
+}
+
+variable "create_cloudwatch_log_group" {
+ description = <<-EOT
+ When true (default), the module creates one CloudWatch log group per log type exported.
+ Log groups are named: /aws/rds/instance//
+
+ Set to false when the log groups are pre-created at consumer level — for example,
+ when you need a specific resource policy, cross-account sharing, or to have the
+ log group managed by a central logging stack. The log groups must exist before the
+ RDS instance is created and must follow the naming convention above.
+
+ When false, cloudwatch_log_group_kms_key_id is not required by this module
+ (encryption is the caller's responsibility on the pre-existing log groups).
+ EOT
+ type = bool
+ default = true
+}
+
+variable "cloudwatch_log_group_retention_in_days" {
+ description = "Number of days to retain CloudWatch log groups created for RDS log exports."
+ type = number
+ default = 30
+}
+
+variable "cloudwatch_log_group_kms_key_id" {
+ description = "ARN of the customer-managed KMS key to encrypt CloudWatch log groups. Required when create_cloudwatch_log_group is true and enabled_cloudwatch_logs_exports is non-empty. AWS-managed keys are not acceptable per platform policy. Not used when create_cloudwatch_log_group is false."
+ type = string
+ default = null
+}
diff --git a/infrastructure/modules/secrets-manager/README.md b/infrastructure/modules/secrets-manager/README.md
index fcb530f6..7646e7ba 100644
--- a/infrastructure/modules/secrets-manager/README.md
+++ b/infrastructure/modules/secrets-manager/README.md
@@ -103,6 +103,60 @@ module "rotated_password" {
}
```
+## Naming Effect: Standard vs Path-Style
+
+The `delimiter` setting determines whether secret names include leading `/` and use `/` as separators.
+
+### Path-style naming with `delimiter = "/"`
+
+When you set `delimiter = "/"`, secret names include a leading `/` and use `/` throughout:
+
+```hcl
+module "path_style_secret" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref="
+
+ context = module.this.context
+ delimiter = "/" # Path-style naming
+ stack = "database"
+ name = "credentials"
+
+ description = "Database credentials"
+ kms_key_id = module.kms.key_arn
+ secret_string = jsonencode({
+ username = "admin"
+ password = var.db_password
+ })
+}
+
+# Results in secret name: /bcss/screening/prod/database/credentials
+# (assuming context: service=bcss, project=screening, environment=prod)
+```
+
+### Standard naming (default delimiter: `-`)
+
+Without explicit `delimiter`, secret names use the default hyphen separator:
+
+```hcl
+module "standard_secret" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref="
+
+ context = module.this.context
+ # delimiter not set — uses default "-"
+ stack = "database"
+ name = "credentials"
+
+ description = "Database credentials"
+ kms_key_id = module.kms.key_arn
+ secret_string = jsonencode({
+ username = "admin"
+ password = var.db_password
+ })
+}
+
+# Results in secret name: bcss-screening-prod-database-credentials
+# (no leading /)
+```
+
## Conventions
- `block_public_policy` is always `true` and cannot be overridden — public access to secrets is never permitted.
@@ -115,7 +169,7 @@ module "rotated_password" {
- `random_password_override_special` overrides the default special character set used during password generation.
- `rotate_immediately` controls whether rotation fires immediately (`true`) or waits for the next scheduled window (`false`); only relevant when `enable_rotation = true`.
- `create_policy` defaults to `false`; set to `true` and provide `policy_statements` to attach a resource-based policy.
-- Secret names are derived from `module.this.id` for consistency with other screening modules.
+- **Secret naming**: Derived from context labels. When `delimiter = "/"`, names are path-style with a leading `/` (e.g., `/bcss/screening/prod/db/credentials`); when using the default delimiter, names are hyphen-separated without a leading `/` (e.g., `bcss-screening-prod-db-credentials`). Override with `secret_name` if custom naming is required.
## What this module does NOT do
@@ -153,6 +207,7 @@ The following constraints are enforced at `plan` time via preconditions in `vali
| Name | Source | Version |
| ---- | ------ | ------- |
| [secret](#module\_secret) | terraform-aws-modules/secrets-manager/aws | 2.1.0 |
+| [secret\_label](#module\_secret\_label) | ../tags | n/a |
| [this](#module\_this) | ../tags | n/a |
## Resources
@@ -201,7 +256,7 @@ The following constraints are enforced at `plan` time via preconditions in `vali
| [rotate\_immediately](#input\_rotate\_immediately) | When enable\_rotation is true, specifies whether to rotate the secret immediately or wait until the next scheduled rotation window. Defaults to immediate rotation when not set. | `bool` | `null` | no |
| [rotation\_lambda\_arn](#input\_rotation\_lambda\_arn) | ARN of the Lambda function that rotates the secret. Required when enable\_rotation is true. | `string` | `""` | no |
| [rotation\_rules](#input\_rotation\_rules) | Rotation schedule for the secret. Provide either automatically\_after\_days or a schedule\_expression (cron/rate). Required when enable\_rotation is true. | object({
automatically_after_days = optional(number)
duration = optional(string)
schedule_expression = optional(string)
}) | `null` | no |
-| [secret\_name](#input\_secret\_name) | Optional explicit name for the secret. When null, the name is derived from context labels via module.this.id. | `string` | `null` | no |
+| [secret\_name](#input\_secret\_name) | Optional explicit name for the secret. When null, the name is derived from context labels via module.secret\_label.id. | `string` | `null` | no |
| [secret\_string](#input\_secret\_string) | The secret value to store as a plaintext string. Use jsonencode() to store structured data such as database credentials. Mutually exclusive with secret\_string\_wo. | `string` | `null` | no |
| [secret\_string\_wo](#input\_secret\_string\_wo) | Write-only variant of secret\_string. The value is accepted by Terraform but never stored in state, making it safer for production secrets. Use jsonencode() for structured data. Mutually exclusive with secret\_string. | `string` | `null` | no |
| [secret\_string\_wo\_version](#input\_secret\_string\_wo\_version) | Trigger value used alongside secret\_string\_wo. Increment this (e.g. a timestamp or counter) whenever the secret value changes, so Terraform knows to push the new value. | `string` | `null` | no |
diff --git a/infrastructure/modules/secrets-manager/locals.tf b/infrastructure/modules/secrets-manager/locals.tf
index 2b565bce..615b0288 100644
--- a/infrastructure/modules/secrets-manager/locals.tf
+++ b/infrastructure/modules/secrets-manager/locals.tf
@@ -1,6 +1,6 @@
locals {
- # Use the caller-supplied secret name if provided; otherwise fall back
- # to the auto-generated context ID. The caller controls the generated
- # name by setting context labels: service, environment, stack, name, etc.
- secret_name = var.secret_name != null ? var.secret_name : module.this.id
+ # Secret name: use custom override if provided, otherwise derive from context
+ # labels via module.secret_label.id (e.g. "bcss-screening-test-db-credentials")
+ # When delimiter is "/", prepend "/" to ensure path-style secrets names
+ secret_name = var.secret_name != null ? var.secret_name : (module.secret_label.delimiter == "/" ? format("/%s", module.secret_label.id) : module.secret_label.id)
}
diff --git a/infrastructure/modules/secrets-manager/main.tf b/infrastructure/modules/secrets-manager/main.tf
index c484a466..a8f2ec68 100644
--- a/infrastructure/modules/secrets-manager/main.tf
+++ b/infrastructure/modules/secrets-manager/main.tf
@@ -4,22 +4,31 @@
# Thin NHS wrapper around the community secrets-manager module
# that enforces the screening platform's baseline controls:
#
-# * Naming: derived from context labels via module.this.id
-# * Tagging: all NHS-required tags applied automatically
+# * Naming: derived from context labels via module.secret_label.id
+# * Tagging: all NHS-required tags applied via module.secret_label.tags
# * Public policy: always blocked (block_public_policy = true)
-# * Enabled flag: create = module.this.enabled
+# * Enabled flag: create = module.secret_label.enabled
#
# Inputs intentionally NOT exposed (hardcoded below):
# - block_public_policy → always true; callers cannot override
#
# Cross-variable input constraints are enforced in validations.tf.
################################################################
+module "secret_label" {
+ source = "../tags"
+
+ # Allow forward slashes for hierarchical secret names
+ regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/"
+
+ context = module.this.context
+}
+
module "secret" {
source = "terraform-aws-modules/secrets-manager/aws"
version = "2.1.0"
- create = module.this.enabled
+ create = module.secret_label.enabled
# Naming — always derived from context labels
name = local.secret_name
@@ -49,5 +58,5 @@ module "secret" {
rotation_rules = var.rotation_rules
# Tags — automatically populated from context
- tags = module.this.tags
+ tags = module.secret_label.tags
}
diff --git a/infrastructure/modules/secrets-manager/validations.tf b/infrastructure/modules/secrets-manager/validations.tf
index 0a758f1f..d7d7597e 100644
--- a/infrastructure/modules/secrets-manager/validations.tf
+++ b/infrastructure/modules/secrets-manager/validations.tf
@@ -11,7 +11,7 @@
################################################################
resource "terraform_data" "validations" {
- count = module.this.enabled ? 1 : 0
+ count = module.secret_label.enabled ? 1 : 0
lifecycle {
precondition {
diff --git a/infrastructure/modules/secrets-manager/variables.tf b/infrastructure/modules/secrets-manager/variables.tf
index 2c47affd..50b773aa 100644
--- a/infrastructure/modules/secrets-manager/variables.tf
+++ b/infrastructure/modules/secrets-manager/variables.tf
@@ -2,11 +2,12 @@
# Secrets Manager-specific inputs.
#
# Naming, tagging and the master `enabled` switch come from
-# context.tf via `module.this`.
+# context.tf via `module.this`, and are processed by `module.secret_label`
+# to provide hierarchical naming support (forward-slash safe).
#
# Inputs NOT exposed here (opinionated defaults hardcoded in main.tf):
# - block_public_policy → always true
-# - name / name_prefix → derived from module.this.id via locals.tf
+# - name / name_prefix → derived from module.secret_label.id via locals.tf
# - secret_binary → not supported; use secret_string
# - replica → replication not required for this use case
# - version_stages → low-value for standard use cases
@@ -19,7 +20,7 @@
variable "secret_name" {
type = string
default = null
- description = "Optional explicit name for the secret. When null, the name is derived from context labels via module.this.id."
+ description = "Optional explicit name for the secret. When null, the name is derived from context labels via module.secret_label.id."
}
################################################################
diff --git a/infrastructure/modules/ssm-parameter/README.md b/infrastructure/modules/ssm-parameter/README.md
index 81e3b3a6..6b25b394 100644
--- a/infrastructure/modules/ssm-parameter/README.md
+++ b/infrastructure/modules/ssm-parameter/README.md
@@ -1,28 +1,29 @@
# SSM Parameter
-NHS Screening wrapper around the community
+Thin NHS wrapper around the community
[`terraform-aws-modules/ssm-parameter/aws`](https://registry.terraform.io/modules/terraform-aws-modules/ssm-parameter/aws/latest)
-module that consumes the shared `context.tf` for naming and tagging.
+module that enforces the screening platform's baseline controls for parameter naming, tagging, and encryption.
## What this module enforces
-| Control | How it is enforced |
-| --- | --- |
-| Naming consistency | Parameter name is derived from context labels; path-style names are normalised to start with `/` |
-| Tagging consistency | Tags are always sourced from `module.this.tags` |
-| SecureString guardrail | `key_id` is mandatory when `type = "SecureString"` via variable validation |
+| Control | How it is enforced | Impact |
+| --- | --- | --- |
+| Naming consistency | Parameter name is derived from context labels; when `delimiter = "/"`, names are path-style and start with `/` | Ensures hierarchical organisation across teams |
+| Tagging consistency | All tags sourced from `module.this.tags` (NHS-standard set) | Ensures billing, compliance, and governance controls |
+| KMS encryption for secrets | `key_id` is **mandatory** when `type = "SecureString"` | No unencrypted secrets in SSM; prevents accidental exposure |
+| Sensitive value protection | `value` and `values` marked `sensitive = true` in Terraform | Prevents secrets from appearing in logs/state diffs |
+| Creation gating | `create = module.ssm_param_label.enabled` | Allows disabling entire module via context |
## Usage
-### Minimal string parameter
+### 1. Minimal string parameter
```hcl
module "app_config_parameter" {
source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
- service = "bcss"
- project = "api"
- environment = "development"
+ context = module.this.context
+ stack = "api"
name = "log-level"
type = "String"
@@ -30,54 +31,248 @@ module "app_config_parameter" {
}
```
-### Production SecureString parameter with customer-managed KMS key
+### 2. JSON-encoded SecureString parameter with KMS encryption (production)
+
+Stores structured secrets (e.g., database credentials) encrypted with a customer-managed KMS key:
+
+```hcl
+module "database_credentials" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ stack = "database"
+ name = "credentials"
+
+ type = "SecureString"
+ key_id = module.kms.key_arn # Required — KMS key is mandatory for SecureString
+
+ # Store structured secrets as JSON — safer than flat strings
+ value = jsonencode({
+ engine = "postgres"
+ host = module.rds.endpoint
+ port = 5432
+ username = "admin"
+ password = var.db_admin_password # Use var with sensitive = true
+ })
+
+ description = "RDS Aurora credentials (JSON format)"
+}
+```
+
+Output usage in consumer stack:
+
+```hcl
+locals {
+ db_creds = jsondecode(module.ssm.parameter_value)
+}
+
+resource "aws_db_instance" "main" {
+ db_name = "screening_db"
+ username = local.db_creds.username
+ password = local.db_creds.password
+ # ...
+}
+```
+
+### 3. API key as SecureString
```hcl
-module "database_password_parameter" {
+module "third_party_api_key" {
source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
- service = "bcss"
- project = "database"
- environment = "prod"
- name = "db-password"
+ context = module.this.context
+ stack = "integration"
+ name = "third-party-api-key"
type = "SecureString"
- value = var.db_password
key_id = module.kms.key_arn
+
+ value = var.api_key # Passed from vars with sensitive = true
+ description = "API key for third-party supplier integration (encrypted with KMS)"
+
+ # Set to true when rotation Lambda updates the value outside Terraform
+ ignore_value_changes = true
}
```
-### Path-style parameter with value change ignored
+### 4. StringList parameter (comma-separated values)
```hcl
-module "shared_api_endpoint" {
+module "allowed_ip_addresses" {
source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
- service = "bcss"
- project = "platform"
- environment = "prod"
- name = "shared/api/base-url"
+ context = module.this.context
+ stack = "security"
+ name = "allowed-ips"
+
+ type = "StringList"
+ values = ["10.0.0.0/8", "192.168.0.0/16"] # Will be JSON-encoded by upstream module
+
+ description = "Allowed source IP ranges for VPN/bastion access"
+}
+```
- type = "String"
- value = "https://api.example.nhs.uk"
+### 5. Parameter with external value management (Secrets Rotation)
+
+When a rotation Lambda manages the secret value outside Terraform:
+
+```hcl
+module "rotated_api_secret" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ stack = "integration"
+ name = "rotated-secret"
+
+ type = "SecureString"
+ key_id = module.kms.key_arn
+
+ value = var.initial_secret_value
+
+ # Prevent Terraform from overwriting rotated values on apply
ignore_value_changes = true
+
+ description = "API secret that is auto-rotated every 30 days by Lambda"
}
```
+### 6. Path-style hierarchical parameter
+
+Store related configuration under a hierarchical path:
+
+```hcl
+module "app_database_config" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ stack = "application"
+ name = "database/connection" # Results in: /////database/connection
+
+ type = "String"
+ value = jsonencode({
+ pool_size = 10
+ timeout = 30
+ retry_count = 3
+ })
+
+ description = "Application database connection pool configuration"
+}
+```
+
+### 7. Write-only parameter (never stored in Terraform state)
+
+For production secrets where state exposure is a compliance risk:
+
+```hcl
+module "production_secret" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ stack = "production"
+ name = "api-secret"
+
+ type = "SecureString"
+ key_id = module.kms.key_arn
+
+ # Use value_wo_version instead of value — secret is never stored in Terraform state
+ # Increment value_wo_version whenever the secret value changes to force Terraform to update
+ value_wo_version = timestamp() # or use a counter: var.secret_version_counter
+
+ description = "Production API secret (never stored in state)"
+}
+```
+
+## Naming Effect: Standard vs Path-Style
+
+The `delimiter` setting determines whether parameter names include leading `/` and use `/` as separators.
+
+### Path-style naming with `delimiter = "/"`
+
+When you set `delimiter = "/"`, the parameter name includes a leading `/` and uses `/` throughout:
+
+```hcl
+module "path_style_parameter" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ delimiter = "/" # Path-style naming
+ stack = "api"
+ name = "config"
+
+ type = "String"
+ value = "prod-value"
+}
+
+# Results in parameter name: /bcss/screening/prod/api/config
+# (assuming context: service=bcss, project=screening, environment=prod)
+```
+
+### Standard naming (default delimiter: `-`)
+
+Without explicit `delimiter`, names use the default hyphen separator and no leading `/`:
+
+```hcl
+module "standard_parameter" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref="
+
+ context = module.this.context
+ # delimiter not set — uses default "-"
+ stack = "api"
+ name = "config"
+
+ type = "String"
+ value = "prod-value"
+}
+
+# Results in parameter name: bcss-screening-prod-api-config
+# (no leading /)
+```
+
## Conventions
-* The parameter name comes from context labels. If `name` contains `/`, the module ensures the final parameter name is fully qualified (starts with `/`).
-* `type` must be one of `String`, `StringList`, or `SecureString`.
-* When using `SecureString`, you must provide `key_id`.
-* `values` are JSON-encoded by the upstream module when storing list-style values.
-* Set `ignore_value_changes = true` when values are managed outside Terraform and should not be reconciled on every apply.
+- **Naming:** Parameter names are derived from context labels. When `delimiter = "/"`, names are path-style with a leading `/` (e.g., `/bcss/prod/app/config`). When using the default delimiter, names are hyphen-separated without a leading `/` (e.g., `bcss-prod-app-config`). Override with `parameter_name` if custom naming is required.
+- **Type selection:**
+ - `String` — for plaintext configuration (URLs, counts, feature flags, etc.)
+ - `StringList` — for comma-separated lists; the module JSON-encodes the values for you
+ - `SecureString` — for sensitive values (passwords, API keys, tokens); **requires `key_id`**
+- **SecureString encryption (MANDATORY):** When `type = "SecureString"`, you **must** provide a `key_id` (KMS key ARN or ID). This is enforced at `terraform plan` time and prevents unencrypted secrets.
+- **Sensitive values:** Use Terraform variables with `sensitive = true` when passing secret values:
+
+ ```hcl
+ variable "db_password" {
+ type = string
+ sensitive = true
+ }
+ ```
+
+- **External value management:** Set `ignore_value_changes = true` when values are rotated by Lambda or other automation. This prevents Terraform from reverting externally-updated values on `apply`.
+- **Write-only secrets:** Use `value_wo_version` (instead of `value`) for compliance-sensitive deployments where secrets must never be stored in Terraform state. Increment the trigger value to force updates.
## What this module does NOT do
-* Create or manage a KMS key. Provide an existing key ARN/ID via `key_id` for `SecureString` parameters.
-* Manage parameter policies (for example expiration, no-change notifications, or advanced policy lifecycle controls).
-* Resolve secrets from external systems. The caller must provide `value`, `values`, or `value_wo_version`.
-* Manage IAM permissions for reading/writing parameters. Attach IAM policies in consumer stacks/modules.
+- **Create KMS keys:** Provide an existing key ARN/ID via `key_id` for `SecureString` parameters; create keys separately using the `kms` module.
+- **Manage parameter policies:** IAM policy attachment, resource-based policies, or access controls are caller responsibility.
+- **Rotate secrets automatically:** Configure rotation using `aws_ssm_parameter` + Lambda directly in consumer stacks if needed. Set `ignore_value_changes = true` to prevent Terraform conflicts.
+- **Resolve secrets from external systems:** The caller must explicitly provide `value`, `values`, or `value_wo_version` — no automatic fetching from Vault, Secrets Manager, or other sources.
+- **Create IAM permissions:** Attach policies to roles/users who need to read/write parameters; this module creates the parameter only.
+- **Share parameters across AWS accounts:** SSM parameters are account-scoped; use AWS Secrets Manager for cross-account sharing or cross-stack references.
+
+## Validation
+
+The following constraints are enforced at `plan` time and prevent invalid configurations before any resources are created:
+
+### Variable-Level Validation
+
+- **Type validation:** `type` must be `String`, `StringList`, or `SecureString` (enforced in variables.tf)
+- **KMS key requirement:** When `type = "SecureString"`, `key_id` is mandatory (enforced in variables.tf)
+- **Parameter name format:** `parameter_name` must start with `/` if overridden (enforced in variables.tf)
+
+### Cross-Variable Validation (enforced in validations.tf)
+
+- **Value mutual exclusivity:** Cannot set both `value` and `values` at the same time — specify only one source
+- **Write-only scope:** `value_wo_version` (version trigger) is only valid when `type = "SecureString"`
+- **Required value source:** At least one of `value`, `values`, or `value_wo_version` must be provided (cannot omit all three)
+- **At least one value source**: One of `value`, `values`, or `value_wo_version` must be provided. Using all three is invalid.
@@ -91,18 +286,23 @@ module "shared_api_endpoint" {
## Providers
-No providers.
+| Name | Version |
+| ---- | ------- |
+| [terraform](#provider\_terraform) | n/a |
## Modules
| Name | Source | Version |
| ---- | ------ | ------- |
+| [ssm\_param\_label](#module\_ssm\_param\_label) | ../tags | n/a |
| [ssm\_parameter](#module\_ssm\_parameter) | terraform-aws-modules/ssm-parameter/aws | 2.1.0 |
| [this](#module\_this) | ../tags | n/a |
## Resources
-No resources.
+| Name | Type |
+| ---- | ---- |
+| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
## Inputs
@@ -132,7 +332,7 @@ No resources.
| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no |
| [overwrite](#input\_overwrite) | Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform | `bool` | `null` | no |
| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no |
-| [path](#input\_path) | Path part of the name of the parameter. Defaults to `///` derived from context. | `string` | `null` | no |
+| [parameter\_name](#input\_parameter\_name) | Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`. | `string` | `null` | no |
| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no |
| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no |
| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no |
@@ -147,9 +347,9 @@ No resources.
| [tier](#input\_tier) | Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource | `string` | `null` | no |
| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no |
| [type](#input\_type) | Type of the parameter. Valid types are `String`, `StringList` and `SecureString` | `string` | n/a | yes |
-| [value](#input\_value) | Value of the parameter | `string` | `null` | no |
+| [value](#input\_value) | Value of the parameter. Can contain secrets such as database passwords or API keys. | `string` | `null` | no |
| [value\_wo\_version](#input\_value\_wo\_version) | Value of the parameter. This value is always marked as sensitive in the Terraform plan output, regardless of type. Additionally, write-only values are never stored to state. `value_wo_version` can be used to trigger an update and is required with this argument | `number` | `null` | no |
-| [values](#input\_values) | List of values of the parameter (will be jsonencoded to store as string natively in SSM) | `list(string)` | `[]` | no |
+| [values](#input\_values) | List of values of the parameter (will be jsonencoded to store as string natively in SSM). Can contain secrets. | `list(string)` | `[]` | no |
| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no |
## Outputs
diff --git a/infrastructure/modules/ssm-parameter/locals.tf b/infrastructure/modules/ssm-parameter/locals.tf
index bc44a012..edec398b 100644
--- a/infrastructure/modules/ssm-parameter/locals.tf
+++ b/infrastructure/modules/ssm-parameter/locals.tf
@@ -1,10 +1,5 @@
locals {
- default_path = format(
- "/%s/%s/%s",
- module.this.service,
- module.this.project,
- module.this.environment
- )
- path = var.path != null ? var.path : local.default_path
- name = "${trimsuffix(local.path, "/")}/${module.this.id}"
+ # Parameter name: use custom override if provided, otherwise derive from context
+ # When delimiter is "/", prepend "/" to ensure path-style parameter names (AWS SSM standard)
+ parameter_name = var.parameter_name != null ? var.parameter_name : (module.ssm_param_label.delimiter == "/" ? format("/%s", module.ssm_param_label.id) : module.ssm_param_label.id)
}
diff --git a/infrastructure/modules/ssm-parameter/main.tf b/infrastructure/modules/ssm-parameter/main.tf
index b0dc9fa4..06fdc738 100644
--- a/infrastructure/modules/ssm-parameter/main.tf
+++ b/infrastructure/modules/ssm-parameter/main.tf
@@ -1,10 +1,33 @@
+################################################################
+# SSM Parameter
+#
+# Thin NHS wrapper around the community ssm-parameter module
+# that enforces the screening platform's baseline controls:
+#
+# * Naming: derived from context labels via module.ssm_param_label.id
+# * Tagging: all NHS-required tags applied via module.ssm_param_label.tags
+# * SecureString: KMS key_id is mandatory (enforced via validation)
+# * Enabled flag: create = module.ssm_param_label.enabled
+#
+# Cross-variable input constraints are enforced in validations.tf.
+################################################################
+
+module "ssm_param_label" {
+ source = "../tags"
+
+ # Allow forward slashes for hierarchical parameter names
+ regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/"
+
+ context = module.this.context
+}
+
module "ssm_parameter" {
source = "terraform-aws-modules/ssm-parameter/aws"
version = "2.1.0"
- create = module.this.enabled
- name = local.name
- tags = module.this.tags
+ create = module.ssm_param_label.enabled
+ name = local.parameter_name
+ tags = module.ssm_param_label.tags
allowed_pattern = var.allowed_pattern
data_type = var.ssm_data_type
diff --git a/infrastructure/modules/ssm-parameter/validations.tf b/infrastructure/modules/ssm-parameter/validations.tf
new file mode 100644
index 00000000..c9e4667c
--- /dev/null
+++ b/infrastructure/modules/ssm-parameter/validations.tf
@@ -0,0 +1,31 @@
+################################################################
+# Input validation
+#
+# Validates cross-variable constraints that cannot be expressed
+# through individual variable validation blocks:
+#
+# * value and values are mutually exclusive — specify only one
+# * value_wo_version (write-only version trigger) only applies
+# to SecureString parameters
+# * At least one value source must be provided (value, values,
+# or value_wo_version)
+################################################################
+
+resource "terraform_data" "validations" {
+ count = module.ssm_param_label.enabled ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = !(var.value != null && length(var.values) > 0)
+ error_message = "value and values are mutually exclusive; specify only one."
+ }
+ precondition {
+ condition = var.type != "SecureString" || var.value_wo_version == null || (var.value != null || var.value_wo_version != null)
+ error_message = "value_wo_version is only valid when type is \"SecureString\"."
+ }
+ precondition {
+ condition = var.value != null || length(var.values) > 0 || var.value_wo_version != null
+ error_message = "At least one of value, values, or value_wo_version must be provided."
+ }
+ }
+}
diff --git a/infrastructure/modules/ssm-parameter/variables.tf b/infrastructure/modules/ssm-parameter/variables.tf
index a676a752..97d2fc69 100644
--- a/infrastructure/modules/ssm-parameter/variables.tf
+++ b/infrastructure/modules/ssm-parameter/variables.tf
@@ -39,14 +39,14 @@ variable "overwrite" {
default = null
}
-variable "path" {
- description = "Path part of the name of the parameter. Defaults to `///` derived from context."
+variable "parameter_name" {
+ description = "Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`."
type = string
default = null
validation {
- condition = var.path == null || can(regex("^/", coalesce(var.path, "/")))
- error_message = "path must start with a forward slash, e.g. \"/bcss\"."
+ condition = var.parameter_name == null || can(regex("^/", coalesce(var.parameter_name, "/")))
+ error_message = "parameter_name must start with a forward slash, e.g. \"/bcss/prod/myapp/config\"."
}
}
@@ -67,9 +67,10 @@ variable "type" {
}
variable "value" {
- description = "Value of the parameter"
+ description = "Value of the parameter. Can contain secrets such as database passwords or API keys."
type = string
default = null
+ sensitive = true
}
variable "value_wo_version" {
@@ -79,7 +80,8 @@ variable "value_wo_version" {
}
variable "values" {
- description = "List of values of the parameter (will be jsonencoded to store as string natively in SSM)"
+ description = "List of values of the parameter (will be jsonencoded to store as string natively in SSM). Can contain secrets."
type = list(string)
default = []
+ sensitive = true
}
diff --git a/infrastructure/modules/vpc-endpoint/.terraform.lock.hcl b/infrastructure/modules/vpc-endpoint/.terraform.lock.hcl
new file mode 100644
index 00000000..caf1d989
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/.terraform.lock.hcl
@@ -0,0 +1,30 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.55.0"
+ constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0"
+ hashes = [
+ "h1:3OSTaIx7d6T+N6gwNtw0gR+IwExbHqb0y4v529Vey/4=",
+ "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=",
+ "h1:QIQoZv637BE40vg7/0gf/5gQbY/ZTngSuJWDl7fJr2s=",
+ "h1:tBAaeIprhWQmlsq/CoHLXWOLZZYRdCVA1wbNv2co55g=",
+ "h1:yM6149NHHl8NWU/OCIV2/x6tLrNgDTRKgR8cVrbk9BU=",
+ "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd",
+ "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966",
+ "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6",
+ "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67",
+ "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881",
+ "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5",
+ "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45",
+ "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899",
+ "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568",
+ "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34",
+ "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f",
+ "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1",
+ "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a",
+ "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51",
+ ]
+}
diff --git a/infrastructure/modules/vpc-endpoint/README.md b/infrastructure/modules/vpc-endpoint/README.md
new file mode 100644
index 00000000..6514d9a0
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/README.md
@@ -0,0 +1,804 @@
+# VPC Endpoints
+
+Thin wrapper around the community [`terraform-aws-modules/vpc/aws//modules/vpc-endpoints`](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest/submodules/vpc-endpoints) submodule for creating Interface and Gateway VPC endpoints with optional per-endpoint policies, default security group assignment, and subnet customization.
+
+## What this module enforces
+
+| Control | How it is enforced |
+| --- | --- |
+| Caller-controlled security | Module does NOT create security groups; caller must create and pass them |
+| Default security groups | Single `security_group_id` can default all Interface endpoints; per-endpoint override via `security_group_ids` |
+| Endpoint policies | Module passes through optional policies for both Gateway (S3/DynamoDB) and Interface (SNS, SQS, etc.) endpoints; caller responsible for restrictive policies |
+| Subnet isolation | Default placement in intra subnets (no internet route); can override per-endpoint |
+| Consistent tagging | All resources inherit `var.tags`; each endpoint also gets a default `Name` tag of `${module.this.id}-${endpoint_key}` unless overridden per-endpoint |
+
+## What this module provides
+
+- **Interface endpoint support** — Creates endpoints in specified subnets (default: all subnets in `var.subnet_ids`)
+- **Gateway endpoint support** — Creates endpoints with route table associations (S3, DynamoDB)
+- **Default security group** — Single `var.security_group_id` applies to all Interface endpoints unless overridden per-endpoint
+- **Default subnets** — Single `var.subnet_ids` applies to all endpoints unless overridden per-endpoint
+- **Per-endpoint overrides** — `security_group_ids`, `subnet_ids`, and `policy` customizable per endpoint
+- **Per-endpoint policies** — Optional endpoint policies for both Gateway and Interface endpoints (recommended for security)
+- **Consistent tagging** — All resources inherit `var.tags`, and each endpoint gets a default `Name` tag with an optional per-endpoint override
+
+## Usage
+
+### Endpoint naming
+
+This wrapper adds a per-endpoint `Name` tag automatically:
+
+- Default: `${module.this.id}-${replace(endpoint_key, ".", "-")}`
+- Override: set `name` on the endpoint object
+
+This is applied by injecting `Name` into the per-endpoint `tags` map before calling the upstream module.
+
+### Advanced upstream attributes
+
+This wrapper passes most per-endpoint settings through to the upstream module unchanged. In addition to the common examples below, you can also use upstream-supported attributes such as:
+
+- `auto_accept`
+- `dns_options`
+- `ip_address_type`
+- `service_endpoint`
+- `service_region`
+- `subnet_configurations`
+- `tags`
+
+The key constraint from this wrapper is that every endpoint object must still include `service`.
+
+### Example 1: Minimal Interface endpoints with default security group
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.vpc_endpoints.id
+
+ endpoints = {
+ ecr_api = {
+ service = "ecr.api"
+ service_type = "Interface"
+ # Inherits security_group_id from var.security_group_id
+ # Name tag defaults to "${module.this.id}-ecr_api"
+ }
+
+ ecr_dkr = {
+ service = "ecr.dkr"
+ service_type = "Interface"
+ # Inherits security_group_id from var.security_group_id
+ # Name tag defaults to "${module.this.id}-ecr_dkr"
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 1a: Override the endpoint `Name` tag
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.vpc_endpoints.id
+
+ endpoints = {
+ secretsmanager = {
+ service = "secretsmanager"
+ service_type = "Interface"
+ name = "platform-secrets-endpoint"
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 2: Gateway endpoint (S3)
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+
+ endpoints = {
+ s3 = {
+ service = "s3"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ # Optional: restrictive policy to limit access
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["s3:GetObject", "s3:ListBucket"]
+ Resource = [
+ "arn:aws:s3:::my-bucket",
+ "arn:aws:s3:::my-bucket/*"
+ ]
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 3: Gateway endpoint (DynamoDB)
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+
+ endpoints = {
+ dynamodb = {
+ service = "dynamodb"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ # Restrict to specific table and actions
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"]
+ Resource = "arn:aws:dynamodb:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:table/my-table"
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 4: Interface endpoint (basic)
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.interface_endpoints.id
+
+ endpoints = {
+ sns = {
+ service = "sns"
+ service_type = "Interface"
+ # Inherits all intra subnets (default)
+ # Inherits security_group_id from var.security_group_id
+ }
+
+ sqs = {
+ service = "sqs"
+ service_type = "Interface"
+ # Inherits all intra subnets (default)
+ # Inherits security_group_id from var.security_group_id
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 5: Interface endpoint with explicit DNS options and endpoint-specific tags
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.interface_endpoints.id
+
+ endpoints = {
+ logs = {
+ service = "logs"
+ service_type = "Interface"
+ name = "central-cloudwatch-logs-endpoint"
+ subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2)
+ private_dns_enabled = true
+ dns_options = {
+ dns_record_ip_type = "ipv4"
+ }
+ tags = {
+ Purpose = "application-observability"
+ Tier = "shared"
+ }
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 6: Interface endpoint with fixed private IPs via subnet configurations
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2)
+ security_group_id = aws_security_group.interface_endpoints.id
+
+ endpoints = {
+ ec2 = {
+ service = "ec2"
+ service_type = "Interface"
+ private_dns_enabled = true
+ ip_address_type = "ipv4"
+ subnet_configurations = [
+ {
+ subnet_id = module.vpc.intra_subnet_ids[0]
+ ipv4 = "10.20.10.10"
+ },
+ {
+ subnet_id = module.vpc.intra_subnet_ids[1]
+ ipv4 = "10.20.20.10"
+ }
+ ]
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+### Example 7: Dual S3 endpoints for VPC and inbound resolver traffic
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.interface_endpoints.id
+
+ endpoints = {
+ s3_gateway = {
+ service = "s3"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["s3:GetObject"]
+ Resource = "arn:aws:s3:::my-ingestion-bucket/*"
+ }]
+ })
+ }
+
+ s3_interface = {
+ service = "s3"
+ service_type = "Interface"
+ private_dns_enabled = true
+ dns_options = {
+ dns_record_ip_type = "ipv4"
+ private_dns_only_for_inbound_resolver_endpoint = true
+ }
+ tags = {
+ Purpose = "on-prem-s3-resolution"
+ }
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+This pattern follows the AWS provider guidance for services that support both Gateway and Interface endpoints: VPC traffic uses the Gateway endpoint, while traffic entering through Route 53 Resolver inbound endpoints can use the Interface endpoint.
+
+### Example 8: PrivateLink to RDS with policy and security group
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+
+ endpoints = {
+ rds = {
+ service = "rds"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.rds_endpoint.id]
+ private_dns_enabled = true
+ # Optional: policy to restrict RDS endpoint access
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["rds:DescribeDBInstances", "rds:DescribeDBClusters"]
+ Resource = "arn:aws:rds:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:db:*"
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+
+# Create RDS-specific security group (separate from generic endpoints)
+resource "aws_security_group" "rds_endpoint" {
+ name_prefix = "rds-endpoint-"
+ vpc_id = module.vpc.vpc_id
+
+ ingress {
+ from_port = 3306
+ to_port = 3306
+ protocol = "tcp"
+ security_groups = [aws_security_group.application_tier.id]
+ }
+}
+```
+
+### Example 9: PrivateLink to EC2 with policy and security group
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+
+ endpoints = {
+ ec2 = {
+ service = "ec2"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.ec2_endpoint.id]
+ # Optional: policy to restrict EC2 API access
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["ec2:DescribeInstances", "ec2:DescribeSecurityGroups"]
+ Resource = "*"
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+
+resource "aws_security_group" "ec2_endpoint" {
+ name_prefix = "ec2-endpoint-"
+ vpc_id = module.vpc.vpc_id
+
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ security_groups = [aws_security_group.automation_tier.id]
+ }
+}
+```
+
+### Example 10: PrivateLink to ELB API with policy, security group, and private DNS
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+
+ endpoints = {
+ elasticloadbalancing = {
+ service = "elasticloadbalancing"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.nlb_endpoint.id]
+ private_dns_enabled = true
+ # Limit NLB API access to describe operations
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = [
+ "elasticloadbalancing:DescribeLoadBalancers",
+ "elasticloadbalancing:DescribeTargetGroups",
+ "elasticloadbalancing:DescribeLoadBalancerAttributes"
+ ]
+ Resource = "*"
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+
+resource "aws_security_group" "nlb_endpoint" {
+ name_prefix = "nlb-endpoint-"
+ vpc_id = module.vpc.vpc_id
+
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ security_groups = [aws_security_group.orchestration_tier.id]
+ }
+
+ tags = {
+ Name = "nlb-endpoint-sg"
+ }
+}
+```
+
+### Example 11: Complex: Multiple endpoint types with mixed defaults and overrides
+
+```hcl
+module "vpc_endpoints" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.intra_subnet_ids
+ security_group_id = aws_security_group.generic_endpoints.id # Default for Interface endpoints
+
+ endpoints = {
+ # Gateway endpoints (no security group)
+ s3 = {
+ service = "s3"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["s3:GetObject"]
+ Resource = "arn:aws:s3:::my-bucket/*"
+ }]
+ })
+ }
+
+ dynamodb = {
+ service = "dynamodb"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ }
+
+ # Interface endpoints - uses default security_group_id
+ ecr_api = {
+ service = "ecr.api"
+ service_type = "Interface"
+ # Inherits var.security_group_id and var.subnet_ids
+ }
+
+ ecr_dkr = {
+ service = "ecr.dkr"
+ service_type = "Interface"
+ # Inherits var.security_group_id and var.subnet_ids
+ }
+
+ # Interface endpoint - overrides security group
+ secretsmanager = {
+ service = "secretsmanager"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.secrets_endpoint.id]
+ # Uses default subnet_ids
+ }
+
+ # Interface endpoint - overrides both security group and subnets (single AZ)
+ ssm = {
+ service = "ssm"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.ssm_endpoint.id]
+ subnet_ids = [module.vpc.intra_subnet_ids[0]] # Cost optimization
+ }
+
+ # Interface endpoint - overrides everything including private DNS
+ rds = {
+ service = "rds"
+ service_type = "Interface"
+ security_group_ids = [aws_security_group.rds_endpoint.id]
+ subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2) # Two AZs for HA
+ private_dns_enabled = true
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { AWS = data.aws_caller_identity.current.arn }
+ Action = "rds-db:connect"
+ Resource = "*"
+ }]
+ })
+ }
+ }
+
+ tags = var.tags
+}
+```
+
+## Conventions
+
+### Security group and subnet handling
+
+**Security groups** (Interface endpoints only):
+
+| Scenario | Behavior |
+| --- | --- |
+| Endpoint specifies `security_group_ids` | Uses those (takes precedence) |
+| Endpoint does NOT specify `security_group_ids` + `var.security_group_id` set | Uses `var.security_group_id` |
+| Endpoint does NOT specify `security_group_ids` + `var.security_group_id = null` | **Validation error** for Interface endpoints |
+| Gateway endpoint | Ignored; does not support security groups |
+
+**Subnets** (all endpoint types):
+
+| Scenario | Behavior |
+| --- | --- |
+| Endpoint specifies `subnet_ids` | Uses those (takes precedence) |
+| Endpoint does NOT specify `subnet_ids` + `var.subnet_ids` set | Uses `var.subnet_ids` for all endpoints |
+| Gateway endpoint with route table placement | `subnet_ids` ignored; uses `route_table_ids` instead |
+
+### Endpoint types and required attributes
+
+| Type | Required attributes | Optional attributes |
+| --- | --- | --- |
+| Interface | `service`, and either per-endpoint `security_group_ids` OR `var.security_group_id` | `auto_accept`, `dns_options`, `ip_address_type`, `policy`, `private_dns_enabled`, `service_endpoint`, `service_region`, `subnet_configurations`, `subnet_ids`, `tags`, `name` |
+| Gateway | `service`, `route_table_ids` | `policy`, `tags`, `name` |
+
+### Naming and variable precedence
+
+The caller is responsible for endpoint keys and service selection. This wrapper also applies endpoint naming conventions:
+
+- **Endpoint logical names**: Use clear, descriptive keys in the `endpoints` map (e.g., `ecr_api`, `s3`, `secretsmanager`)
+- **Endpoint `Name` tag**: Defaults to `${module.this.id}-${replace(endpoint_key, ".", "-")}`
+- **Endpoint `Name` override**: Set `name` on an endpoint to override only that endpoint's `Name` tag
+- **Service names**: AWS service names (e.g., `s3`, `ecr.api`, `ecr.dkr`) are passed directly to the upstream module
+- **Subnet placement**: Default to `intra_subnet_ids` (no internet route); override per-endpoint via `subnet_ids` for cost optimization
+
+### Caller responsibilities
+
+This module does **NOT** create:
+
+- **Security groups** — Create these at the stack level and pass `security_group_ids` per-endpoint or `var.security_group_id` as default
+- **Subnets** — Caller must provide `var.subnet_ids` (default placement) and per-endpoint `subnet_ids` overrides
+- **Endpoint policies** — Caller must supply if access restriction is needed (default: open to all principals)
+- **Route tables** — For Gateway endpoints, pass existing route table IDs
+- **IAM policies** — Caller owns any IAM permissions to consume the endpoints
+
+### AWS service names by endpoint type
+
+**Interface endpoints** (most AWS services):
+
+- Container services: `ecr.api`, `ecr.dkr`
+- Secrets: `secretsmanager`, `ssm`
+- SNS/SQS: `sns`, `sqs`
+- CloudWatch: `logs`, `monitoring`
+- Many others; see [AWS documentation](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-service.html)
+
+**Gateway endpoints** (only):
+
+- `s3`
+- `dynamodb`
+
+## Security Considerations
+
+### Endpoint policies (Gateway endpoints only: S3, DynamoDB)
+
+**Gateway endpoints** (S3, DynamoDB) support endpoint policies to restrict access. **By default, VPC endpoints allow ALL principals and actions.** This is a significant security risk in most deployments. Always supply restrictive `policy` attributes for Gateway endpoints:
+
+```hcl
+endpoints = {
+ s3 = {
+ service = "s3"
+ service_type = "Gateway"
+ route_table_ids = module.vpc.private_route_table_ids
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = "*"
+ Action = ["s3:GetObject"]
+ Resource = "arn:aws:s3:::my-bucket/*"
+ Condition = {
+ StringEquals = {
+ "aws:PrincipalOrgID" = var.organization_id
+ }
+ }
+ }]
+ })
+ }
+}
+```
+
+Without policies, any IAM principal in your account (or federated users) can access any S3 bucket or service through the endpoint.
+
+**Some Interface endpoints also support policies**, but support is service-specific. Treat security groups as the primary network control, and only rely on endpoint policies where the AWS service documentation explicitly supports them.
+
+### Security groups for Interface endpoints
+
+Interface endpoints require security groups to:
+
+- Restrict source IP ranges (e.g., only from specific security groups or subnets)
+- Restrict ports and protocols
+
+**Best practice**: Create dedicated security groups per endpoint service rather than sharing a single group:
+
+```hcl
+resource "aws_security_group" "ecr_endpoints" {
+ vpc_id = module.vpc.vpc_id
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ security_groups = [aws_security_group.container_workloads.id]
+ }
+}
+
+resource "aws_security_group" "secrets_endpoints" {
+ vpc_id = module.vpc.vpc_id
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ security_groups = [aws_security_group.application_tier.id]
+ }
+}
+```
+
+### Subnet placement
+
+- **Intra subnets (default)**: No internet route; isolated and secure for sensitive services
+- **Private subnets**: Internet route through NAT; appropriate if endpoints serve public internet access needs
+- **Per-endpoint override**: Use sparingly; document the reason for non-default subnet placement
+
+## What this module does NOT do
+
+- **Does NOT create security groups** — You must create security groups at the stack level and pass them
+- **Does NOT create IAM policies** — IAM permissions to consume endpoints are the caller's responsibility
+- **Does NOT enforce endpoint policies** — Endpoints default to open access; you must supply restrictive policies
+- **Does NOT manage route table associations** — For Gateway endpoints, you pass existing route tables
+- **Does NOT rename AWS service identifiers** — `service` values are passed through to the upstream module; only the resource `Name` tag is defaulted/overridable
+- **Does NOT create DNS records** — Private DNS for Interface endpoints is configured via `private_dns_enabled`; caller owns Route53 setup if needed
+- **Does NOT manage VPCs or subnets** — Caller must provide `vpc_id` and `subnet_ids` from the VPC module
+
+## Troubleshooting
+
+### "Duplicate security group" error
+
+Ensure you're creating security groups **outside** the endpoints module:
+
+```hcl
+# ✓ Correct: security group created separately, passed to module
+resource "aws_security_group" "vpc_endpoints" {
+ vpc_id = module.vpc.vpc_id
+}
+
+module "vpc_endpoints" {
+ endpoints = {
+ ecr_api = {
+ service = "ecr.api"
+ security_group_ids = [aws_security_group.vpc_endpoints.id]
+ }
+ }
+}
+```
+
+### "Endpoint not available" error
+
+For Gateway endpoints (S3, DynamoDB), ensure route tables include a route to the endpoint:
+
+```hcl
+endpoints = {
+ s3 = {
+ service = "s3"
+ service_type = "Gateway"
+ route_table_ids = [module.vpc.private_route_table_ids[0]]
+ }
+}
+```
+
+### "Permission denied" when using endpoints
+
+If you can't access services via endpoints, verify:
+
+1. **Endpoint policy** — If set, does it allow your IAM principal and action?
+2. **Security group** — Are inbound rules permissive enough (port 443 for HTTPS)?
+3. **DNS** — Is `private_dns_enabled = true` so DNS resolves to the endpoint?
+
+## References
+
+- [AWS VPC Endpoints documentation](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints.html)
+- [Interface vs Gateway endpoints](https://docs.aws.amazon.com/vpc/latest/privatelink/vpce-interface.html)
+- [Endpoint policy examples](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-access.html)
+- [Upstream module documentation](https://github.com/terraform-aws-modules/terraform-aws-vpc/tree/master/modules/vpc-endpoints)
+
+
+
+
+## Requirements
+
+| Name | Version |
+| ---- | ------- |
+| [terraform](#requirement\_terraform) | >= 1.13 |
+| [aws](#requirement\_aws) | >= 6.42 |
+
+## Providers
+
+No providers.
+
+## Modules
+
+| Name | Source | Version |
+| ---- | ------ | ------- |
+| [this](#module\_this) | ../tags | n/a |
+| [vpc\_endpoints](#module\_vpc\_endpoints) | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | 6.6.1 |
+
+## Resources
+
+No resources.
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+| ---- | ----------- | ---- | ------- | :------: |
+| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no |
+| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no |
+| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no |
+| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no |
+| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` | {
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
} | no |
+| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no |
+| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no |
+| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no |
+| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no |
+| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no |
+| [endpoints](#input\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints submodule.
**Interface endpoints** (default):
- Placed in intra subnets by default (can override via subnet\_ids)
- Require security\_group\_ids
- Support optional private\_dns\_enabled (default true)
**Gateway endpoints**:
- Must specify service\_type = "Gateway"
- Require route\_table\_ids
- Do NOT use security\_group\_ids or subnet\_ids
Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api") [REQUIRED]
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document (optional but recommended)
subnet\_ids - Override default intra subnets (optional; Interface only)
security\_group\_ids - Security group IDs (required for Interface endpoints)
private\_dns\_enabled - Enable private DNS (Interface only; default true)
route\_table\_ids - Route table IDs (required for Gateway endpoints)
tags - Per-endpoint tags (optional)
**Security consideration:** Endpoint policies restrict access. If not specified,
the endpoint allows all principals. Recommended to set restrictive policies. | `any` | `{}` | no |
+| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no |
+| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no |
+| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no |
+| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no |
+| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no |
+| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` | [
"default"
]
| no |
+| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no |
+| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no |
+| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no |
+| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no |
+| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no |
+| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no |
+| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no |
+| [security\_group\_id](#input\_security\_group\_id) | Default security group ID to associate with Interface endpoints. Per-endpoint security\_group\_ids override this default. Can be null if all Interface endpoints specify security\_group\_ids explicitly. | `string` | `null` | no |
+| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no |
+| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no |
+| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no |
+| [subnet\_ids](#input\_subnet\_ids) | List of subnet IDs to use as default for Interface endpoints (recommended: intra subnets with no internet route). Can be overridden per-endpoint via subnet\_ids in the endpoints map. | `list(string)` | n/a | yes |
+| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no |
+| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no |
+| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no |
+| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no |
+| [vpc\_id](#input\_vpc\_id) | The ID of the VPC where endpoints will be created. | `string` | n/a | yes |
+| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no |
+
+## Outputs
+
+| Name | Description |
+| ---- | ----------- |
+| [endpoints](#output\_endpoints) | Map of VPC endpoints created, keyed by logical name. Contains all endpoint attributes (id, arn, network\_interface\_ids, subnet\_ids, security\_groups, etc.). |
+| [security\_groups](#output\_security\_groups) | Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create\_security\_group=false). |
+| [validate\_endpoint\_policies](#output\_validate\_endpoint\_policies) | Informational: Policy coverage for Gateway endpoints. Both Gateway and Interface endpoints support policies; Gateway endpoints (S3, DynamoDB) should have restrictive policies as a security best practice. |
+| [validate\_endpoints\_not\_empty](#output\_validate\_endpoints\_not\_empty) | Informational: Confirms endpoints are specified |
+| [validate\_gateway\_endpoint\_config](#output\_validate\_gateway\_endpoint\_config) | Validation status: Ensures Gateway endpoints have route\_table\_ids and do not have security\_group\_ids |
+| [validate\_security\_group\_coverage](#output\_validate\_security\_group\_coverage) | Validation status: Ensures Interface endpoints have security group coverage (either per-endpoint security\_group\_ids or module-level var.security\_group\_id as default) |
+
+
+
diff --git a/infrastructure/modules/vpc-endpoint/context.tf b/infrastructure/modules/vpc-endpoint/context.tf
new file mode 100644
index 00000000..b644ebfb
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/context.tf
@@ -0,0 +1,377 @@
+# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations
+#
+#
+# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags
+# All other instances of this file should be a copy of that one
+#
+#
+# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf
+# and then place it in your Terraform module to automatically get
+# tag module standard configuration inputs suitable for passing
+# to other modules.
+#
+# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf
+#
+# Modules should access the whole context as `module.this.context`
+# to get the input variables with nulls for defaults,
+# for example `context = module.this.context`,
+# and access individual variables as `module.this.`,
+# with final values filled in.
+#
+# For example, when using defaults, `module.this.context.delimiter`
+# will be null, and `module.this.delimiter` will be `-` (hyphen).
+#
+
+module "this" {
+ source = "../tags"
+
+ enabled = var.enabled
+ service = var.service
+ project = var.project
+ region = var.region
+ environment = var.environment
+ stack = var.stack
+ workspace = var.workspace
+ name = var.name
+ delimiter = var.delimiter
+ attributes = var.attributes
+ tags = var.tags
+ additional_tag_map = var.additional_tag_map
+ label_order = var.label_order
+ regex_replace_chars = var.regex_replace_chars
+ id_length_limit = var.id_length_limit
+ label_key_case = var.label_key_case
+ label_value_case = var.label_value_case
+ terraform_source = coalesce(var.terraform_source, path.module)
+ descriptor_formats = var.descriptor_formats
+ labels_as_tags = var.labels_as_tags
+
+ context = var.context
+}
+
+# Copy contents of screening-terraform-modules-aws/tags/variables.tf here
+# tflint-ignore: terraform_unused_declarations
+variable "aws_region" {
+ type = string
+ description = "The AWS region"
+ default = "eu-west-2"
+ validation {
+ condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region)
+ error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1"
+ }
+}
+
+variable "context" {
+ type = any
+ default = {
+ enabled = true
+ service = null
+ project = null
+ region = null
+ environment = null
+ stack = null
+ workspace = null
+ name = null
+ delimiter = null
+ attributes = []
+ tags = {}
+ additional_tag_map = {}
+ regex_replace_chars = null
+ label_order = []
+ id_length_limit = null
+ label_key_case = null
+ label_value_case = null
+ terraform_source = null
+ descriptor_formats = {}
+ # Note: we have to use [] instead of null for unset lists due to
+ # https://github.com/hashicorp/terraform/issues/28137
+ # which was not fixed until Terraform 1.0.0,
+ # but we want the default to be all the labels in `label_order`
+ # and we want users to be able to prevent all tag generation
+ # by setting `labels_as_tags` to `[]`, so we need
+ # a different sentinel to indicate "default"
+ labels_as_tags = ["unset"]
+ }
+ description = <<-EOT
+ Single object for setting entire context at once.
+ See description of individual variables for details.
+ Leave string and numeric variables as `null` to use default value.
+ Individual variable settings (non-null) override settings in context object,
+ except for attributes, tags, and additional_tag_map, which are merged.
+ EOT
+
+ validation {
+ condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"])
+ error_message = "Allowed values: `lower`, `title`, `upper`."
+ }
+
+ validation {
+ condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"])
+ error_message = "Allowed values: `lower`, `title`, `upper`, `none`."
+ }
+}
+
+variable "terraform_source" {
+ type = string
+ default = null
+ description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set."
+}
+
+variable "enabled" {
+ type = bool
+ default = null
+ description = "Set to false to prevent the module from creating any resources"
+}
+
+variable "service" {
+ type = string
+ default = null
+ description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique"
+}
+
+variable "region" {
+ type = string
+ default = null
+ description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region"
+}
+
+variable "project" {
+ type = string
+ default = null
+ description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`"
+}
+variable "stack" {
+ type = string
+ default = null
+ description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`"
+}
+variable "workspace" {
+ type = string
+ default = null
+ description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces"
+}
+variable "environment" {
+ type = string
+ default = null
+ description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'"
+}
+
+variable "name" {
+ type = string
+ default = null
+ description = <<-EOT
+ ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
+ This is the only ID element not also included as a `tag`.
+ The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input.
+ EOT
+}
+
+variable "delimiter" {
+ type = string
+ default = null
+ description = <<-EOT
+ Delimiter to be used between ID elements.
+ Defaults to `-` (hyphen). Set to `""` to use no delimiter at all.
+ EOT
+}
+
+variable "attributes" {
+ type = list(string)
+ default = []
+ description = <<-EOT
+ ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
+ in the order they appear in the list. New attributes are appended to the
+ end of the list. The elements of the list are joined by the `delimiter`
+ and treated as a single ID element.
+ EOT
+}
+
+variable "labels_as_tags" {
+ type = set(string)
+ default = ["default"]
+ description = <<-EOT
+ Set of labels (ID elements) to include as tags in the `tags` output.
+ Default is to include all labels.
+ Tags with empty values will not be included in the `tags` output.
+ Set to `[]` to suppress all generated tags.
+ **Notes:**
+ The value of the `name` tag, if included, will be the `id`, not the `name`.
+ Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
+ changed in later chained modules. Attempts to change it will be silently ignored.
+ EOT
+}
+
+variable "tags" {
+ type = map(string)
+ default = {}
+ description = <<-EOT
+ Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
+ Neither the tag keys nor the tag values will be modified by this module.
+ EOT
+}
+
+variable "additional_tag_map" {
+ type = map(string)
+ default = {}
+ description = <<-EOT
+ Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
+ This is for some rare cases where resources want additional configuration of tags
+ and therefore take a list of maps with tag key, value, and additional configuration.
+ EOT
+}
+
+variable "label_order" {
+ type = list(string)
+ default = null
+ description = <<-EOT
+ The order in which the labels (ID elements) appear in the `id`.
+ Defaults to ["namespace", "environment", "stage", "name", "attributes"].
+ You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present.
+ EOT
+}
+
+variable "regex_replace_chars" {
+ type = string
+ default = null
+ description = <<-EOT
+ Terraform regular expression (regex) string.
+ Characters matching the regex will be removed from the ID elements.
+ If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits.
+ EOT
+}
+
+variable "id_length_limit" {
+ type = number
+ default = null
+ description = <<-EOT
+ Limit `id` to this many characters (minimum 6).
+ Set to `0` for unlimited length.
+ Set to `null` for keep the existing setting, which defaults to `0`.
+ Does not affect `id_full`.
+ EOT
+ validation {
+ condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0
+ error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length."
+ }
+}
+
+variable "label_key_case" {
+ type = string
+ default = null
+ description = <<-EOT
+ Controls the letter case of the `tags` keys (label names) for tags generated by this module.
+ Does not affect keys of tags passed in via the `tags` input.
+ Possible values: `lower`, `title`, `upper`.
+ Default value: `title`.
+ EOT
+
+ validation {
+ condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case)
+ error_message = "Allowed values: `lower`, `title`, `upper`."
+ }
+}
+
+variable "label_value_case" {
+ type = string
+ default = null
+ description = <<-EOT
+ Controls the letter case of ID elements (labels) as included in `id`,
+ set as tag values, and output by this module individually.
+ Does not affect values of tags passed in via the `tags` input.
+ Possible values: `lower`, `title`, `upper` and `none` (no transformation).
+ Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
+ Default value: `lower`.
+ EOT
+
+ validation {
+ condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case)
+ error_message = "Allowed values: `lower`, `title`, `upper`, `none`."
+ }
+}
+
+variable "descriptor_formats" {
+ type = any
+ default = {}
+ description = <<-EOT
+ Describe additional descriptors to be output in the `descriptors` output map.
+ Map of maps. Keys are names of descriptors. Values are maps of the form
+ `{
+ format = string
+ labels = list(string)
+ }`
+ (Type is `any` so the map values can later be enhanced to provide additional options.)
+ `format` is a Terraform format string to be passed to the `format()` function.
+ `labels` is a list of labels, in order, to pass to `format()` function.
+ Label values will be normalized before being passed to `format()` so they will be
+ identical to how they appear in `id`.
+ Default is `{}` (`descriptors` output will be empty).
+ EOT
+}
+
+variable "owner" {
+ type = string
+ description = "The name and or NHS.net email address of the service owner"
+ default = "None"
+}
+
+variable "tag_version" {
+ type = string
+ description = "Used to identify the tagging version in use"
+ default = "1.0"
+}
+
+variable "data_classification" {
+ type = string
+ description = "Used to identify the data classification of the resource, e.g 1-5"
+ default = "n/a"
+ validation {
+ condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification)
+ error_message = "Data Classification must be \"n/a\" or between 1-5"
+ }
+}
+
+variable "data_type" {
+ type = string
+ description = "The tag data_type"
+ default = "None"
+ validation {
+ condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type)
+ error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit"
+ }
+}
+
+
+variable "public_facing" {
+ type = bool
+ description = "Whether this resource is public facing"
+ default = false
+}
+
+variable "service_category" {
+ type = string
+ description = "The tag service_category"
+ default = "n/a"
+ validation {
+ condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category)
+ error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum"
+ }
+}
+variable "on_off_pattern" {
+ type = string
+ description = "Used to turn resources on and off based on a time pattern"
+ default = "n/a"
+}
+
+variable "application_role" {
+ type = string
+ description = "The role the application is performing"
+ default = "General"
+}
+
+variable "tool" {
+ type = string
+ description = "The tool used to deploy the resource"
+ default = "Terraform"
+}
+
+#### End of copy of screening-terraform-modules-aws/tags/variables.tf
diff --git a/infrastructure/modules/vpc-endpoint/locals.tf b/infrastructure/modules/vpc-endpoint/locals.tf
new file mode 100644
index 00000000..bab7dd47
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/locals.tf
@@ -0,0 +1,45 @@
+################################################################
+# Local values
+#
+# Merges per-endpoint configuration with module defaults (var.security_group_id).
+# Interface endpoints use either their specified security_group_ids or the default.
+################################################################
+
+locals {
+ # Intelligently merge endpoints with module defaults (var.security_group_id, var.subnet_ids)
+ # Precedence: per-endpoint values > module defaults > upstream defaults
+ # This enables a caller to set defaults while allowing per-endpoint overrides.
+ endpoints_merged = {
+ for k, v in var.endpoints :
+ k => merge(
+ v,
+ # Merge security_group_ids: per-endpoint > default
+ # Only add security_group_ids if:
+ # 1. Endpoint doesn't already specify them AND
+ # 2. var.security_group_id is set AND
+ # 3. Endpoint is Interface type (or unspecified, defaulting to Interface)
+ try(v.security_group_ids, null) == null &&
+ var.security_group_id != null &&
+ try(v.service_type, "Interface") == "Interface" ?
+ { security_group_ids = [var.security_group_id] } :
+ {},
+ # Merge subnet_ids: per-endpoint > default
+ # Only add subnet_ids if:
+ # 1. Endpoint doesn't already specify them AND
+ # 2. var.subnet_ids is set
+ try(v.subnet_ids, null) == null &&
+ length(var.subnet_ids) > 0 ?
+ { subnet_ids = var.subnet_ids } :
+ {},
+ # Merge tags: inject a default Name tag of "${module.this.id}-${key}"
+ # Consumer can override by setting `name` on the endpoint object.
+ # Per-endpoint tags from v.tags are preserved alongside the injected Name.
+ {
+ tags = merge(
+ try(v.tags, {}),
+ { Name = try(v.name, "${module.this.id}-${replace(k, ".", "-")}") }
+ )
+ }
+ )
+ }
+}
diff --git a/infrastructure/modules/vpc-endpoint/main.tf b/infrastructure/modules/vpc-endpoint/main.tf
new file mode 100644
index 00000000..e970ea21
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/main.tf
@@ -0,0 +1,29 @@
+################################################################
+# VPC Endpoints Wrapper Module
+#
+# Creates Interface and Gateway VPC endpoints with support for:
+# - Optional per-endpoint policies
+# - Per-endpoint subnet overrides
+# - Security group assignment (caller-managed)
+# - Custom tags per endpoint
+################################################################
+
+module "vpc_endpoints" {
+ source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
+ version = "6.6.1"
+
+ vpc_id = var.vpc_id
+
+ # Default subnet placement: intra (no internet route, high isolation)
+ # Can be overridden per-endpoint via subnet_ids in endpoints map
+ subnet_ids = var.subnet_ids
+
+ # Security groups are created/managed at the stack level (caller responsibility)
+ # Per-endpoint security_group_ids override the default var.security_group_id
+ create_security_group = false
+
+ # Use merged endpoints map to apply default security_group_id where needed
+ endpoints = local.endpoints_merged
+
+ tags = module.this.tags
+}
diff --git a/infrastructure/modules/vpc-endpoint/outputs.tf b/infrastructure/modules/vpc-endpoint/outputs.tf
new file mode 100644
index 00000000..20314562
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/outputs.tf
@@ -0,0 +1,57 @@
+################################################################
+# Outputs
+#
+# Expose all VPC endpoints created by the upstream module.
+# For advanced use cases (e.g., extracting specific endpoint IDs),
+# callers can access full endpoint attributes via this output.
+#
+# Validation outputs below provide status and enforce preconditions
+# on endpoint configuration (security groups, policies, route tables).
+################################################################
+
+output "endpoints" {
+ description = "Map of VPC endpoints created, keyed by logical name. Contains all endpoint attributes (id, arn, network_interface_ids, subnet_ids, security_groups, etc.)."
+ value = module.vpc_endpoints.endpoints
+}
+
+output "security_groups" {
+ description = "Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create_security_group=false)."
+ value = try(module.vpc_endpoints.security_groups, {})
+}
+
+# Validation outputs: Provide status and enforce preconditions on endpoint configuration
+
+output "validate_security_group_coverage" {
+ description = "Validation status: Ensures Interface endpoints have security group coverage (either per-endpoint security_group_ids or module-level var.security_group_id as default)"
+ value = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null ? "Valid: Security group coverage satisfied" : "Invalid: See error above"
+
+ precondition {
+ condition = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null
+ error_message = "Interface endpoints (${join(", ", local.interface_endpoints_without_sgs)}) must have security_group_ids specified per-endpoint or use var.security_group_id as default."
+ }
+}
+
+output "validate_gateway_endpoint_config" {
+ description = "Validation status: Ensures Gateway endpoints have route_table_ids and do not have security_group_ids"
+ value = (length(local.gateway_endpoints_with_sgs) == 0 && length(local.gateway_endpoints_without_rtb) == 0) ? "Valid: Gateway endpoint configuration satisfied" : "Invalid: See error above"
+
+ precondition {
+ condition = length(local.gateway_endpoints_with_sgs) == 0
+ error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_with_sgs)}) do not support security_group_ids. Remove this attribute or change service_type to Interface."
+ }
+
+ precondition {
+ condition = length(local.gateway_endpoints_without_rtb) == 0
+ error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_without_rtb)}) require route_table_ids to specify which route tables to associate with."
+ }
+}
+
+output "validate_endpoint_policies" {
+ description = "Informational: Policy coverage for Gateway endpoints. Both Gateway and Interface endpoints support policies; Gateway endpoints (S3, DynamoDB) should have restrictive policies as a security best practice."
+ value = length(local.gateway_endpoints_without_policies) == 0 ? "All Gateway endpoints have policies" : "Warning: Gateway endpoints (${join(", ", local.gateway_endpoints_without_policies)}) do not have restrictive policies. Recommended for security."
+}
+
+output "validate_endpoints_not_empty" {
+ description = "Informational: Confirms endpoints are specified"
+ value = "Endpoints defined"
+}
diff --git a/infrastructure/modules/vpc-endpoint/validations.tf b/infrastructure/modules/vpc-endpoint/validations.tf
new file mode 100644
index 00000000..236cf09f
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/validations.tf
@@ -0,0 +1,36 @@
+################################################################
+# Validations (output preconditions)
+#
+# Enforce security and configuration requirements across endpoints.
+################################################################
+
+locals {
+ # Extract interface endpoints that don't have security_group_ids specified
+ interface_endpoints_without_sgs = [
+ for k, v in var.endpoints :
+ k if try(v.service_type, "Interface") == "Interface" && try(v.security_group_ids, null) == null
+ ]
+
+ # Check if any Gateway endpoints mistakenly have security_group_ids
+ gateway_endpoints_with_sgs = [
+ for k, v in var.endpoints :
+ k if try(v.service_type, "Interface") == "Gateway" && try(v.security_group_ids, null) != null
+ ]
+
+ # Gateway endpoints without policies (security best practice)
+ # Both Gateway and Interface endpoints support policies.
+ # Gateway endpoints (S3, DynamoDB) especially should have restrictive policies.
+ gateway_endpoints_without_policies = [
+ for k, v in var.endpoints :
+ k if try(v.service_type, "Interface") == "Gateway" && try(v.policy, null) == null
+ ]
+
+ # Gateway endpoints without route_table_ids
+ gateway_endpoints_without_rtb = [
+ for k, v in var.endpoints :
+ k if try(v.service_type, "Interface") == "Gateway" && try(v.route_table_ids, null) == null
+ ]
+}
+
+# Validation preconditions are now in outputs.tf
+# This file contains locals and variable validations only.
diff --git a/infrastructure/modules/vpc-endpoint/variables.tf b/infrastructure/modules/vpc-endpoint/variables.tf
new file mode 100644
index 00000000..d08e8cd7
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/variables.tf
@@ -0,0 +1,98 @@
+################################################################
+# VPC Configuration
+################################################################
+
+variable "vpc_id" {
+ description = "The ID of the VPC where endpoints will be created."
+ type = string
+
+ validation {
+ condition = can(regex("^vpc-[a-f0-9]{17}$", var.vpc_id))
+ error_message = "vpc_id must be a valid AWS VPC ID (vpc-xxxxxxxxxxxxxxxx)."
+ }
+}
+
+variable "subnet_ids" {
+ description = "List of subnet IDs to use as default for Interface endpoints (recommended: intra subnets with no internet route). Can be overridden per-endpoint via subnet_ids in the endpoints map."
+ type = list(string)
+
+ validation {
+ condition = length(var.subnet_ids) > 0
+ error_message = "subnet_ids must contain at least one subnet ID."
+ }
+}
+
+variable "security_group_id" {
+ description = "Default security group ID to associate with Interface endpoints. Per-endpoint security_group_ids override this default. Can be null if all Interface endpoints specify security_group_ids explicitly."
+ type = string
+ default = null
+
+ validation {
+ condition = var.security_group_id == null || can(regex("^sg-[a-f0-9]{17}$", var.security_group_id))
+ error_message = "security_group_id must be a valid AWS security group ID (sg-xxxxxxxxxxxxxxxx) or null."
+ }
+}
+
+################################################################
+# VPC Endpoints Configuration
+################################################################
+
+variable "endpoints" {
+ description = <<-EOT
+ Map of VPC endpoints to create. Each key is a logical name,
+ each value is passed through to the upstream vpc-endpoints submodule.
+
+ **Interface endpoints** (default):
+ - Placed in intra subnets by default (can override via subnet_ids)
+ - Require security_group_ids
+ - Support optional private_dns_enabled (default true)
+
+ **Gateway endpoints**:
+ - Must specify service_type = "Gateway"
+ - Require route_table_ids
+ - Do NOT use security_group_ids or subnet_ids
+
+ Supported per-endpoint attributes:
+ service - AWS service name (e.g. "s3", "ecr.api") [REQUIRED]
+ service_type - "Interface" (default) or "Gateway"
+ policy - JSON endpoint policy document (optional but recommended)
+ subnet_ids - Override default intra subnets (optional; Interface only)
+ security_group_ids - Security group IDs (required for Interface endpoints)
+ private_dns_enabled - Enable private DNS (Interface only; default true)
+ route_table_ids - Route table IDs (required for Gateway endpoints)
+ tags - Per-endpoint tags (optional)
+
+ **Security consideration:** Endpoint policies restrict access. If not specified,
+ the endpoint allows all principals. Recommended to set restrictive policies.
+ EOT
+ type = any
+ default = {}
+
+ validation {
+ condition = alltrue([for k, v in var.endpoints : try(v.service, null) != null])
+ error_message = "Each endpoint must specify 'service' (e.g. 's3', 'ecr.api')."
+ }
+
+ validation {
+ condition = alltrue([
+ for k, v in var.endpoints :
+ try(v.service_type, "Interface") != "Interface" || var.security_group_id != null || (try(v.security_group_ids, null) != null && length(try(v.security_group_ids, [])) > 0)
+ ])
+ error_message = "Interface endpoints must have security groups via either var.security_group_id (default) or per-endpoint security_group_ids."
+ }
+
+ validation {
+ condition = alltrue([
+ for k, v in var.endpoints :
+ try(v.service_type, "Interface") == "Gateway" ?
+ try(v.route_table_ids, null) != null && length(try(v.route_table_ids, [])) > 0 :
+ true
+ ])
+ error_message = "Gateway endpoints must specify route_table_ids with at least one route table."
+ }
+
+ validation {
+ condition = length(var.endpoints) > 0
+ error_message = "At least one endpoint must be specified. If vpc-endpoint module is not needed, remove it from the configuration."
+ }
+}
diff --git a/infrastructure/modules/vpc-endpoint/versions.tf b/infrastructure/modules/vpc-endpoint/versions.tf
new file mode 100644
index 00000000..5db7ed74
--- /dev/null
+++ b/infrastructure/modules/vpc-endpoint/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.13"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.42" # Platform baseline; terraform-aws-modules/vpc 6.6.1 supports this
+ }
+ }
+}
diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md
index 2e290a8c..49dd62a2 100644
--- a/infrastructure/modules/vpc/README.md
+++ b/infrastructure/modules/vpc/README.md
@@ -48,26 +48,110 @@ Subnet CIDRs are auto-calculated from the VPC CIDR across the first three availa
## Usage
+### Standard VPC (all subnet tiers)
+
```terraform
module "vpc" {
source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref="
- environment = "dev"
+ environment = "prod"
service = "bcss"
name = "vpc"
vpc_cidr = "10.0.0.0/16"
- single_nat_gateway = true # cost saving for non-prod
+ single_nat_gateway = false # one NAT per AZ for HA
flow_log_kms_key_id = aws_kms_key.cloudwatch.arn # optional encryption
}
```
+### Database VPC (intra subnets only, /24 CIDR)
+
+Minimal VPC for databases with no internet access. Uses /26 intra subnets (64 IPs × 3 AZs).
+
+```terraform
+module "database_vpc" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref="
+
+ environment = "prod"
+ service = "bcss"
+ name = "database"
+
+ vpc_cidr = "10.1.0.0/24"
+
+ # Intra subnets only — disable other tiers
+ create_firewall_subnets = false
+ create_public_subnets = false
+ create_private_subnets = false
+ create_intra_subnets = true
+
+ # Adjust subnet prefix for /24 VPC (must be larger than /24, e.g., /26, /27, /28)
+ intra_subnet_prefix = 26
+
+ enable_flow_log = true
+ flow_log_retention_in_days = 30
+}
+```
+
+### Network Firewall routing (firewall + public subnets only)
+
+For centralized Network Firewall inspection. Public subnets route outbound traffic via firewall VPCE (not IGW).
+
+```terraform
+module "vpc_nfw" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref="
+
+ environment = "prod"
+ service = "bcss"
+ name = "nfw"
+
+ vpc_cidr = "10.0.0.0/16"
+
+ # Firewall + public only — disable private and intra
+ create_firewall_subnets = true
+ create_public_subnets = true
+ create_private_subnets = false
+ create_intra_subnets = false
+
+ enable_network_firewall = true # enables firewall routing mode
+
+ # Inject 0.0.0.0/0 → firewall VPCE into public route tables at stack level
+}
+```
+
+### Minimal public-only scenario
+
+For simple public-facing resources without private egress.
+
+```terraform
+module "vpc_public" {
+ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref="
+
+ environment = "dev"
+ service = "test"
+ name = "public"
+
+ vpc_cidr = "10.0.0.0/16"
+
+ # Public subnets only
+ create_firewall_subnets = false
+ create_public_subnets = true
+ create_private_subnets = false
+ create_intra_subnets = false
+
+ map_public_ip_on_launch = true # auto-assign public IPs
+}
+```
+
## Key variables
| Variable | Description | Default |
| --- | --- | --- |
| `vpc_cidr` | VPC CIDR block (/16 to /28 per AWS limits) | Required |
+| `create_firewall_subnets` | Whether to create firewall subnets (required for Network Firewall routing mode) | `true` |
+| `create_public_subnets` | Whether to create public subnets (internet-facing resources, NAT gateways) | `true` |
+| `create_private_subnets` | Whether to create private subnets (NAT-routed workloads with internet access) | `true` |
+| `create_intra_subnets` | Whether to create intra subnets (no internet access) | `true` |
| `availability_zones` | Explicit AZs for subnet placement; defaults to the first three available AZs | `null` |
| `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` |
| `enable_flow_log` | Enable VPC flow logs | `true` |
@@ -103,6 +187,7 @@ module "vpc" {
| Name | Version |
| ---- | ------- |
| [aws](#provider\_aws) | 6.51.0 |
+| [terraform](#provider\_terraform) | n/a |
## Modules
@@ -111,7 +196,6 @@ module "vpc" {
| [flow\_log](#module\_flow\_log) | terraform-aws-modules/vpc/aws//modules/flow-log | 6.6.1 |
| [this](#module\_this) | ../tags | n/a |
| [vpc](#module\_vpc) | terraform-aws-modules/vpc/aws | 6.6.1 |
-| [vpc\_endpoints](#module\_vpc\_endpoints) | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | 6.6.1 |
## Resources
@@ -124,6 +208,7 @@ module "vpc" {
| [aws_route_table_association.edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource |
| [aws_route_table_association.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource |
| [aws_subnet.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource |
+| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [aws_availability_zones.available](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zones) | data source |
## Inputs
@@ -137,7 +222,10 @@ module "vpc" {
| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no |
| [cloudwatch\_log\_group\_tags](#input\_cloudwatch\_log\_group\_tags) | Additional tags for the CloudWatch log group. | `map(string)` | `{}` | no |
| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` | {
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
} | no |
-| [create\_vpc\_endpoints](#input\_create\_vpc\_endpoints) | Whether to create VPC endpoints. | `bool` | `true` | no |
+| [create\_firewall\_subnets](#input\_create\_firewall\_subnets) | Whether to create firewall subnets (required for Network Firewall routing mode). | `bool` | `true` | no |
+| [create\_intra\_subnets](#input\_create\_intra\_subnets) | Whether to create intra subnets (no internet access). | `bool` | `true` | no |
+| [create\_private\_subnets](#input\_create\_private\_subnets) | Whether to create private subnets (workloads with outbound internet access via NAT gateway). | `bool` | `true` | no |
+| [create\_public\_subnets](#input\_create\_public\_subnets) | Whether to create public subnets (internet-facing resources, NAT gateways). | `bool` | `true` | no |
| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no |
| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no |
| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no |
@@ -150,10 +238,11 @@ module "vpc" {
| [enable\_dns\_hostnames](#input\_enable\_dns\_hostnames) | Enable DNS hostnames in the VPC. | `bool` | `true` | no |
| [enable\_dns\_support](#input\_enable\_dns\_support) | Enable DNS support in the VPC. | `bool` | `true` | no |
| [enable\_flow\_log](#input\_enable\_flow\_log) | Enable VPC flow logs to CloudWatch Logs. | `bool` | `true` | no |
+| [enable\_nat\_gateway](#input\_enable\_nat\_gateway) | Provision NAT Gateway(s) for private subnet internet egress. Not applicable if private subnets are disabled. Set to false for database-only VPCs with no internet-routed workloads. | `bool` | `true` | no |
| [enable\_network\_firewall](#input\_enable\_network\_firewall) | When true, the VPC module creates firewall subnets, takes over
IGW management from the community module, and reconfigures
routing for AWS Network Firewall inspection:
- Firewall subnets created as standalone resources
- IGW created as a standalone resource (community module's create\_igw = false)
- Firewall subnets get a default route (0.0.0.0/0) to the IGW
- Public subnet default route is NOT created (callers must
inject 0.0.0.0/0 → firewall VPCE at the stack level)
When false (default), no firewall subnets are created, the
community module creates the IGW and public → IGW route as
normal — no Network Firewall in the path. | `bool` | `false` | no |
| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no |
| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no |
-| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs. | `number` | `28` | no |
+| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when firewall\_subnets list is empty; when explicit firewall\_subnets are provided, this value is ignored. Highly recommended: /28 to minimize wasted IPs. | `number` | `28` | no |
| [firewall\_subnet\_tags](#input\_firewall\_subnet\_tags) | Additional tags for the firewall subnets. | `map(string)` | `{}` | no |
| [firewall\_subnets](#input\_firewall\_subnets) | Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no |
| [flow\_log\_kms\_key\_id](#input\_flow\_log\_kms\_key\_id) | ARN of a KMS key to encrypt the CloudWatch log group. Leave null for no encryption. | `string` | `null` | no |
@@ -163,7 +252,7 @@ module "vpc" {
| [flow\_log\_traffic\_type](#input\_flow\_log\_traffic\_type) | The type of traffic to capture. Valid values: ACCEPT, REJECT, ALL. | `string` | `"ALL"` | no |
| [iam\_role\_tags](#input\_iam\_role\_tags) | Additional tags for the IAM role used by the VPC flow log. | `map(string)` | `{}` | no |
| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no |
-| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no |
+| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when intra\_subnets list is empty; when explicit intra\_subnets are provided, this value is ignored. | `number` | `23` | no |
| [intra\_subnet\_tags](#input\_intra\_subnet\_tags) | Additional tags for the intra (no-internet) subnets. | `map(string)` | `{}` | no |
| [intra\_subnets](#input\_intra\_subnets) | Explicit CIDR blocks for intra subnets with no internet route (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no |
| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no |
@@ -176,26 +265,26 @@ module "vpc" {
| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no |
| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no |
| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no |
-| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no |
+| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when private\_subnets list is empty; when explicit private\_subnets are provided, this value is ignored. | `number` | `23` | no |
| [private\_subnet\_tags](#input\_private\_subnet\_tags) | Additional tags for the private (NAT-routed) subnets. | `map(string)` | `{}` | no |
| [private\_subnets](#input\_private\_subnets) | Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no |
| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no |
| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no |
-| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `24` | no |
+| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when public\_subnets list is empty; when explicit public\_subnets are provided, this value is ignored. | `number` | `24` | no |
| [public\_subnet\_tags](#input\_public\_subnet\_tags) | Additional tags for the public subnets. | `map(string)` | `{}` | no |
| [public\_subnets](#input\_public\_subnets) | Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no |
| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no |
| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no |
| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no |
| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no |
-| [single\_nat\_gateway](#input\_single\_nat\_gateway) | Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. | `bool` | `false` | no |
+| [single\_nat\_gateway](#input\_single\_nat\_gateway) | Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. Requires enable\_nat\_gateway = true and create\_private\_subnets = true. | `bool` | `false` | no |
| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no |
| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no |
| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no |
| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no |
| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no |
+| [vpc\_block\_public\_access\_exclusions](#input\_vpc\_block\_public\_access\_exclusions) | Map of exclusions to the account-wide VPC Block Public Access policy. Use to exempt specific subnets (e.g., public subnets) when account-wide blocking is enabled. See: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options | `map(any)` | `{}` | no |
| [vpc\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC (AWS allows /16 to /28 netmask). Subnet CIDR blocks are auto-calculated from this VPC CIDR using the *\_subnet\_prefix variables. | `string` | n/a | yes |
-| [vpc\_endpoints](#input\_vpc\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints
submodule.
Interface endpoints are placed in intra subnets by default.
Security groups must be created at the stack level and passed
per-endpoint via `security_group_ids`.
Gateway endpoints require `service_type = "Gateway"` and
`route_table_ids`.
Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api")
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document
subnet\_ids - Override default intra subnets
security\_group\_ids - Security group IDs for this endpoint
private\_dns\_enabled - Enable private DNS (Interface only)
route\_table\_ids - Route table IDs (Gateway only)
tags - Per-endpoint tags | `any` | `{}` | no |
| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no |
## Outputs
@@ -227,7 +316,6 @@ module "vpc" {
| [public\_subnets\_cidr\_blocks](#output\_public\_subnets\_cidr\_blocks) | List of CIDR blocks of the public subnets. |
| [vpc\_arn](#output\_vpc\_arn) | The ARN of the VPC. |
| [vpc\_cidr\_block](#output\_vpc\_cidr\_block) | The primary CIDR block of the VPC. |
-| [vpc\_endpoints](#output\_vpc\_endpoints) | Map of VPC endpoints created, keyed by the logical name. |
| [vpc\_id](#output\_vpc\_id) | The ID of the VPC. |
diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf
index 41282ef8..fa771b4e 100644
--- a/infrastructure/modules/vpc/locals.tf
+++ b/infrastructure/modules/vpc/locals.tf
@@ -23,20 +23,34 @@ locals {
private_newbits = var.private_subnet_prefix - local.vpc_prefix_length
intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length
- # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N]
- # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs.
+ # Build a flat list of newbits, conditionally including only enabled subnet types.
+ # cidrsubnets() only carves address space for subnet types that are actually created,
+ # allowing minimal VPCs (e.g., intra-only from a /24 CIDR).
auto_newbits = concat(
- [for _ in range(local.az_count) : local.firewall_newbits],
- [for _ in range(local.az_count) : local.public_newbits],
- [for _ in range(local.az_count) : local.private_newbits],
- [for _ in range(local.az_count) : local.intra_newbits],
+ var.create_firewall_subnets ? [for _ in range(local.az_count) : local.firewall_newbits] : [],
+ var.create_public_subnets ? [for _ in range(local.az_count) : local.public_newbits] : [],
+ var.create_private_subnets ? [for _ in range(local.az_count) : local.private_newbits] : [],
+ var.create_intra_subnets ? [for _ in range(local.az_count) : local.intra_newbits] : [],
)
- auto_subnets = cidrsubnets(var.vpc_cidr, local.auto_newbits...)
+ auto_subnets = length(local.auto_newbits) > 0 ? cidrsubnets(var.vpc_cidr, local.auto_newbits...) : []
- auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count)
- auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count)
- auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count)
- auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count)
+ # Calculate slice boundaries dynamically based on which subnet types are enabled
+ firewall_start = 0
+ firewall_end = var.create_firewall_subnets ? local.az_count : 0
+
+ public_start = local.firewall_end
+ public_end = local.public_start + (var.create_public_subnets ? local.az_count : 0)
+
+ private_start = local.public_end
+ private_end = local.private_start + (var.create_private_subnets ? local.az_count : 0)
+
+ intra_start = local.private_end
+ intra_end = local.intra_start + (var.create_intra_subnets ? local.az_count : 0)
+
+ auto_firewall_subnets = var.create_firewall_subnets ? slice(local.auto_subnets, local.firewall_start, local.firewall_end) : []
+ auto_public_subnets = var.create_public_subnets ? slice(local.auto_subnets, local.public_start, local.public_end) : []
+ auto_private_subnets = var.create_private_subnets ? slice(local.auto_subnets, local.private_start, local.private_end) : []
+ auto_intra_subnets = var.create_intra_subnets ? slice(local.auto_subnets, local.intra_start, local.intra_end) : []
# Allow explicit overrides per tier
firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets
diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf
index d4dce53c..db9d5ecc 100644
--- a/infrastructure/modules/vpc/main.tf
+++ b/infrastructure/modules/vpc/main.tf
@@ -10,6 +10,8 @@
# intra – no internet route (default /23)
#
# Naming and tagging are derived from context.tf via module.this.
+#
+# Cross-variable input constraints are enforced in validations.tf.
################################################################
module "vpc" {
@@ -38,7 +40,7 @@ module "vpc" {
create_multiple_public_route_tables = var.enable_network_firewall
# NAT gateway configuration
- enable_nat_gateway = true
+ enable_nat_gateway = var.enable_nat_gateway
single_nat_gateway = var.single_nat_gateway
one_nat_gateway_per_az = !var.single_nat_gateway
@@ -69,32 +71,20 @@ module "vpc" {
private_subnet_tags = var.private_subnet_tags
intra_subnet_tags = var.intra_subnet_tags
+ # VPC Block Public Access exclusions
+ #
+ # Account-wide VPC Block Public Access (aws_vpc_block_public_access_options) must be
+ # managed outside this module, at the account level:
+ # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options
+ #
+ # Per-VPC subnet exclusions can be specified here to exempt specific subnets from the
+ # account-wide block (e.g., public subnets that legitimately need internet access).
+ vpc_block_public_access_exclusions = length(var.vpc_block_public_access_exclusions) > 0 ? var.vpc_block_public_access_exclusions : {}
+
# Exclude "Name" — the community module sets its own Name tags on all resources
tags = { for k, v in module.this.tags : k => v if k != "Name" }
}
-check "subnet_prefix_vs_vpc_prefix" {
- assert {
- condition = var.firewall_subnet_prefix > local.vpc_prefix_length
- error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})."
- }
-
- assert {
- condition = var.public_subnet_prefix > local.vpc_prefix_length
- error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})."
- }
-
- assert {
- condition = var.private_subnet_prefix > local.vpc_prefix_length
- error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})."
- }
-
- assert {
- condition = var.intra_subnet_prefix > local.vpc_prefix_length
- error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})."
- }
-}
-
################################################################
# Firewall subnets
#
@@ -237,40 +227,3 @@ module "flow_log" {
tags = module.this.tags
}
-
-################################################################
-# VPC Endpoints
-#
-# Uses the standalone vpc-endpoints submodule from
-# terraform-aws-modules/vpc/aws.
-#
-# Interface endpoints default to intra subnets (no internet
-# route needed – they use AWS PrivateLink). Override per-endpoint
-# with subnet_ids inside the endpoints map.
-#
-# Gateway endpoints (S3, DynamoDB) are attached to route tables
-# specified per-endpoint via route_table_ids.
-#
-# Security groups are NOT managed here – callers should create
-# them at the stack level using the security-group module and
-# pass security_group_ids per-endpoint.
-################################################################
-
-module "vpc_endpoints" {
- source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
- version = "6.6.1"
-
- create = module.this.enabled && var.create_vpc_endpoints
-
- vpc_id = module.vpc.vpc_id
-
- # Default subnet placement: intra (no internet route)
- subnet_ids = module.vpc.intra_subnets
-
- # Security groups are managed at the stack level
- create_security_group = false
-
- endpoints = var.vpc_endpoints
-
- tags = module.this.tags
-}
diff --git a/infrastructure/modules/vpc/outputs.tf b/infrastructure/modules/vpc/outputs.tf
index 56f47a68..f8407110 100644
--- a/infrastructure/modules/vpc/outputs.tf
+++ b/infrastructure/modules/vpc/outputs.tf
@@ -163,15 +163,6 @@ output "flow_log_iam_role_arn" {
value = module.flow_log.iam_role_arn
}
-################################################################
-# VPC Endpoints
-################################################################
-
-output "vpc_endpoints" {
- description = "Map of VPC endpoints created, keyed by the logical name."
- value = module.vpc_endpoints.endpoints
-}
-
################################################################
# Edge route table
################################################################
diff --git a/infrastructure/modules/vpc/validations.tf b/infrastructure/modules/vpc/validations.tf
new file mode 100644
index 00000000..df93ece3
--- /dev/null
+++ b/infrastructure/modules/vpc/validations.tf
@@ -0,0 +1,103 @@
+################################################################
+# Input validation
+#
+# Validates cross-variable constraints that cannot be expressed
+# through individual variable validation blocks:
+#
+# * At least one subnet type must be enabled (create_* flag = true)
+# * Subnet prefix must be more specific than VPC CIDR (only when
+# auto-calculating that subnet type — if explicit subnets are
+# provided, the prefix is ignored)
+# * enable_network_firewall requires firewall subnets to be created
+# * single_nat_gateway requires private subnets to be created
+# * Explicit subnet CIDR lists must have correct length (equal to az_count)
+#
+################################################################
+
+resource "terraform_data" "validations" {
+ count = module.this.enabled ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = (
+ var.create_firewall_subnets ||
+ var.create_public_subnets ||
+ var.create_private_subnets ||
+ var.create_intra_subnets
+ )
+ error_message = "At least one subnet type must be created (set one or more of: create_firewall_subnets, create_public_subnets, create_private_subnets, create_intra_subnets to true)."
+ }
+
+ # Prefix validations: only apply when auto-calculating (explicit subnets are empty)
+ precondition {
+ condition = (
+ !var.create_firewall_subnets ||
+ length(var.firewall_subnets) > 0 ||
+ var.firewall_subnet_prefix > local.vpc_prefix_length
+ )
+ error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating firewall subnets (firewall_subnets is empty). When using explicit firewall_subnets, the prefix is ignored."
+ }
+
+ precondition {
+ condition = (
+ !var.create_public_subnets ||
+ length(var.public_subnets) > 0 ||
+ var.public_subnet_prefix > local.vpc_prefix_length
+ )
+ error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating public subnets (public_subnets is empty). When using explicit public_subnets, the prefix is ignored."
+ }
+
+ precondition {
+ condition = (
+ !var.create_private_subnets ||
+ length(var.private_subnets) > 0 ||
+ var.private_subnet_prefix > local.vpc_prefix_length
+ )
+ error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating private subnets (private_subnets is empty). When using explicit private_subnets, the prefix is ignored."
+ }
+
+ precondition {
+ condition = (
+ !var.create_intra_subnets ||
+ length(var.intra_subnets) > 0 ||
+ var.intra_subnet_prefix > local.vpc_prefix_length
+ )
+ error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating intra subnets (intra_subnets is empty). When using explicit intra_subnets, the prefix is ignored."
+ }
+
+ precondition {
+ condition = !var.enable_network_firewall || var.create_firewall_subnets
+ error_message = "enable_network_firewall = true requires create_firewall_subnets = true."
+ }
+
+ precondition {
+ condition = !var.single_nat_gateway || var.create_private_subnets
+ error_message = "single_nat_gateway = true requires create_private_subnets = true (NAT gateway must route traffic to private subnets)."
+ }
+
+ precondition {
+ condition = !var.enable_nat_gateway || var.create_private_subnets
+ error_message = "enable_nat_gateway = true requires create_private_subnets = true. For database-only or intra-only VPCs without private subnets, set enable_nat_gateway = false."
+ }
+
+ precondition {
+ condition = length(var.firewall_subnets) == 0 || length(var.firewall_subnets) == local.az_count
+ error_message = "firewall_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.firewall_subnets)}."
+ }
+
+ precondition {
+ condition = length(var.public_subnets) == 0 || length(var.public_subnets) == local.az_count
+ error_message = "public_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.public_subnets)}."
+ }
+
+ precondition {
+ condition = length(var.private_subnets) == 0 || length(var.private_subnets) == local.az_count
+ error_message = "private_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.private_subnets)}."
+ }
+
+ precondition {
+ condition = length(var.intra_subnets) == 0 || length(var.intra_subnets) == local.az_count
+ error_message = "intra_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.intra_subnets)}."
+ }
+ }
+}
diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf
index 1134e77d..936ca933 100644
--- a/infrastructure/modules/vpc/variables.tf
+++ b/infrastructure/modules/vpc/variables.tf
@@ -54,47 +54,78 @@ variable "availability_zones" {
default = null
}
+################################################################
+# Subnet type creation flags
+#
+# Control which subnet tiers are created. When a subnet type
+# is disabled, its prefix and CIDR calculations are skipped.
+################################################################
+
+variable "create_firewall_subnets" {
+ description = "Whether to create firewall subnets (required for Network Firewall routing mode)."
+ type = bool
+ default = true
+}
+
+variable "create_public_subnets" {
+ description = "Whether to create public subnets (internet-facing resources, NAT gateways)."
+ type = bool
+ default = true
+}
+
+variable "create_private_subnets" {
+ description = "Whether to create private subnets (workloads with outbound internet access via NAT gateway)."
+ type = bool
+ default = true
+}
+
+variable "create_intra_subnets" {
+ description = "Whether to create intra subnets (no internet access)."
+ type = bool
+ default = true
+}
+
variable "firewall_subnet_prefix" {
- description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs."
+ description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when firewall_subnets list is empty; when explicit firewall_subnets are provided, this value is ignored. Highly recommended: /28 to minimize wasted IPs."
type = number
default = 28
validation {
- condition = length(var.firewall_subnets) == 0 ? (var.firewall_subnet_prefix >= 16 && var.firewall_subnet_prefix <= 28) : true
- error_message = "Subnet prefix must be between /16 and /28 per AWS limits."
+ condition = var.firewall_subnet_prefix >= 16 && var.firewall_subnet_prefix <= 28
+ error_message = "firewall_subnet_prefix must be between /16 and /28 per AWS limits."
}
}
variable "public_subnet_prefix" {
- description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix."
+ description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when public_subnets list is empty; when explicit public_subnets are provided, this value is ignored."
type = number
default = 24
validation {
- condition = length(var.public_subnets) == 0 ? (var.public_subnet_prefix >= 16 && var.public_subnet_prefix <= 28) : true
- error_message = "Subnet prefix must be between /16 and /28 per AWS limits."
+ condition = var.public_subnet_prefix >= 16 && var.public_subnet_prefix <= 28
+ error_message = "public_subnet_prefix must be between /16 and /28 per AWS limits."
}
}
variable "private_subnet_prefix" {
- description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix."
+ description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when private_subnets list is empty; when explicit private_subnets are provided, this value is ignored."
type = number
default = 23
validation {
- condition = length(var.private_subnets) == 0 ? (var.private_subnet_prefix >= 16 && var.private_subnet_prefix <= 28) : true
- error_message = "Subnet prefix must be between /16 and /28 per AWS limits."
+ condition = var.private_subnet_prefix >= 16 && var.private_subnet_prefix <= 28
+ error_message = "private_subnet_prefix must be between /16 and /28 per AWS limits."
}
}
variable "intra_subnet_prefix" {
- description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix."
+ description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when intra_subnets list is empty; when explicit intra_subnets are provided, this value is ignored."
type = number
default = 23
validation {
- condition = length(var.intra_subnets) == 0 ? (var.intra_subnet_prefix >= 16 && var.intra_subnet_prefix <= 28) : true
- error_message = "Subnet prefix must be between /16 and /28 per AWS limits."
+ condition = var.intra_subnet_prefix >= 16 && var.intra_subnet_prefix <= 28
+ error_message = "intra_subnet_prefix must be between /16 and /28 per AWS limits."
}
}
@@ -133,8 +164,14 @@ variable "intra_subnets" {
# NAT Gateway
################################################################
+variable "enable_nat_gateway" {
+ description = "Provision NAT Gateway(s) for private subnet internet egress. Not applicable if private subnets are disabled. Set to false for database-only VPCs with no internet-routed workloads."
+ type = bool
+ default = true
+}
+
variable "single_nat_gateway" {
- description = "Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability."
+ description = "Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. Requires enable_nat_gateway = true and create_private_subnets = true."
type = bool
default = false
}
@@ -215,6 +252,25 @@ variable "manage_default_network_acl" {
default = true
}
+################################################################
+# VPC Block Public Access
+#
+# Account-wide Block Public Access (enabling/disabling the policy
+# itself) must be managed at the account level using the
+# aws_vpc_block_public_access_options resource — NOT in this module:
+# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options
+#
+# This module only exposes per-VPC subnet-level exclusions, allowing
+# specific subnets (e.g., public subnets with legitimate internet
+# access) to be exempt from an account-wide block.
+################################################################
+
+variable "vpc_block_public_access_exclusions" {
+ description = "Map of exclusions to the account-wide VPC Block Public Access policy. Use to exempt specific subnets (e.g., public subnets) when account-wide blocking is enabled. See: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options"
+ type = map(any)
+ default = {}
+}
+
################################################################
# Subnet tags
################################################################
@@ -307,37 +363,3 @@ variable "iam_role_tags" {
################################################################
# VPC Endpoints
-################################################################
-
-variable "create_vpc_endpoints" {
- description = "Whether to create VPC endpoints."
- type = bool
- default = true
-}
-
-variable "vpc_endpoints" {
- description = <<-EOT
- Map of VPC endpoints to create. Each key is a logical name,
- each value is passed through to the upstream vpc-endpoints
- submodule.
-
- Interface endpoints are placed in intra subnets by default.
- Security groups must be created at the stack level and passed
- per-endpoint via `security_group_ids`.
-
- Gateway endpoints require `service_type = "Gateway"` and
- `route_table_ids`.
-
- Supported per-endpoint attributes:
- service - AWS service name (e.g. "s3", "ecr.api")
- service_type - "Interface" (default) or "Gateway"
- policy - JSON endpoint policy document
- subnet_ids - Override default intra subnets
- security_group_ids - Security group IDs for this endpoint
- private_dns_enabled - Enable private DNS (Interface only)
- route_table_ids - Route table IDs (Gateway only)
- tags - Per-endpoint tags
- EOT
- type = any
- default = {}
-}
diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml
index 2913a4f1..d35247e6 100644
--- a/scripts/config/generate-available-modules.yaml
+++ b/scripts/config/generate-available-modules.yaml
@@ -181,12 +181,16 @@ vpc:
description: "VPC with subnets, routing, and gateways"
wraps: "terraform-aws-modules/vpc/aws"
+vpc-endpoint:
+ description: "VPC endpoints (Interface and Gateway) with policies"
+ wraps: "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
+
vpce:
- description: "VPC endpoint (single service)"
+ description: "VPC endpoint (single service) (legacy)"
wraps: "—"
vpces:
- description: "VPC endpoints (multiple services)"
+ description: "VPC endpoints (multiple services) (legacy)"
wraps: "—"
waf: