IaC AWS

Terraform Module: AWS Client VPN — managed OpenVPN remote access with auth, subnet associations, and authz rules in one call

Quick take — Reusable Terraform module for AWS Client VPN: provision aws_ec2_client_vpn_endpoint with certificate/AD/SAML auth, multi-subnet associations, split-tunnel, authorization rules, and connection logging on hashicorp/aws ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "client_vpn" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-client-vpn?ref=v1.0.0"

  name                   = "..."  # Logical name, used as the `Name` tag (1–255 chars).
  vpc_id                 = "..."  # VPC the endpoint belongs to (validated as `vpc-…`).
  server_certificate_arn = "..."  # ACM ARN of the server certificate presented to clients.
  client_cidr_block      = "..."  # Client IP pool (≥ /22); must not overlap any associated…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS Client VPN is a managed, OpenVPN-based remote-access VPN. Instead of running your own VPN servers, you create an aws_ec2_client_vpn_endpoint, hand users an .ovpn profile, and they tunnel into your VPCs (and on-prem networks reachable from them). It is the remote-user counterpart to Site-to-Site VPN — Site-to-Site joins whole networks with IPsec, Client VPN connects individual laptops with TLS.

A correct, production Client VPN is never just the endpoint resource. It needs four things wired together, and getting the order and the arguments right is where teams trip up:

This module wraps all four behind typed, validated inputs so a consumer supplies an ACM cert, a client CIDR, a list of subnets, and a list of authorized CIDRs — and gets a fully associated, authorized, logged endpoint with split-tunnel and sane timeouts, rather than hand-assembling the resource quartet for every environment.

When to use it

Module structure

terraform-module-aws-client-vpn/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # endpoint + network associations + authz rules + routes
├── variables.tf     # typed, validated inputs
└── outputs.tf       # endpoint id/arn, dns name, association + SG ids

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  base_tags = merge(
    {
      Name      = var.name
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-client-vpn"
    },
    var.tags,
  )

  # Mutual (certificate) auth requires a client root certificate ARN;
  # AD and federated (SAML) auth do not.
  is_certificate_auth = var.authentication_type == "certificate-authentication"
  is_ad_auth          = var.authentication_type == "directory-service-authentication"
  is_federated_auth   = var.authentication_type == "federated-authentication"
}

resource "aws_ec2_client_vpn_endpoint" "this" {
  description            = var.description
  server_certificate_arn = var.server_certificate_arn

  # Pool that VPN clients are assigned from. Must be a /22 or larger and must
  # NOT overlap any subnet/VPC CIDR the endpoint is associated with.
  client_cidr_block = var.client_cidr_block

  transport_protocol     = var.transport_protocol
  vpn_port               = var.vpn_port
  split_tunnel           = var.split_tunnel
  self_service_portal    = var.self_service_portal ? "enabled" : "disabled"
  session_timeout_hours  = var.session_timeout_hours
  dns_servers            = length(var.dns_servers) > 0 ? var.dns_servers : null
  security_group_ids     = length(var.security_group_ids) > 0 ? var.security_group_ids : null
  vpc_id                 = var.vpc_id

  authentication_options {
    type = var.authentication_type

    # Mutual certificate auth.
    root_certificate_chain_arn = local.is_certificate_auth ? var.root_certificate_chain_arn : null

    # Active Directory (Directory Service) auth.
    active_directory_id = local.is_ad_auth ? var.active_directory_id : null

    # Federated (SAML / IAM Identity Center) auth.
    saml_provider_arn            = local.is_federated_auth ? var.saml_provider_arn : null
    self_service_saml_provider_arn = local.is_federated_auth ? var.self_service_saml_provider_arn : null
  }

  connection_log_options {
    enabled               = var.connection_logging_enabled
    cloudwatch_log_group  = var.connection_logging_enabled ? var.cloudwatch_log_group_name : null
    cloudwatch_log_stream = var.connection_logging_enabled ? var.cloudwatch_log_stream_name : null
  }

  # Optional client login banner (shown on connect for legal/AUP notices).
  dynamic "client_login_banner_options" {
    for_each = var.client_login_banner_text != null ? [1] : []
    content {
      enabled     = true
      banner_text = var.client_login_banner_text
    }
  }

  # Optional client connect Lambda handler (device posture / extra checks).
  dynamic "client_connect_options" {
    for_each = var.client_connect_lambda_arn != null ? [1] : []
    content {
      enabled             = true
      lambda_function_arn = var.client_connect_lambda_arn
    }
  }

  tags = local.base_tags
}

# Associate the endpoint with one subnet per AZ you want to serve.
# Each association places an ENI and is billed per hour.
resource "aws_ec2_client_vpn_network_association" "this" {
  for_each = toset(var.subnet_ids)

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.this.id
  subnet_id              = each.value
}

# Authorization rules: an explicit allow-list of destination CIDRs.
# Without at least one rule, connected clients can reach nothing.
resource "aws_ec2_client_vpn_authorization_rule" "this" {
  for_each = { for r in var.authorization_rules : "${r.target_network_cidr}-${coalesce(r.access_group_id, "all")}" => r }

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.this.id
  target_network_cidr    = each.value.target_network_cidr

  # Either authorize all groups, or scope the rule to a single AD/SAML group SID.
  authorize_all_groups = each.value.access_group_id == null ? true : null
  access_group_id      = each.value.access_group_id
  description          = each.value.description
}

# Extra routes for split-tunnel egress beyond the associated subnet
# (e.g. peered VPCs, on-prem via TGW, or 0.0.0.0/0 for full internet).
resource "aws_ec2_client_vpn_route" "this" {
  for_each = { for r in var.additional_routes : "${r.destination_cidr_block}-${r.target_subnet_id}" => r }

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.this.id
  destination_cidr_block = each.value.destination_cidr_block
  target_vpc_subnet_id   = each.value.target_subnet_id
  description            = each.value.description

  # A route's target subnet must already be associated.
  depends_on = [aws_ec2_client_vpn_network_association.this]
}

variables.tf

variable "name" {
  description = "Logical name for the endpoint, used as the Name tag (e.g. \"prod-remote-access\")."
  type        = string

  validation {
    condition     = length(var.name) > 0 && length(var.name) <= 255
    error_message = "name must be between 1 and 255 characters."
  }
}

variable "description" {
  description = "Free-text description for the Client VPN endpoint."
  type        = string
  default     = "Managed by Terraform"
}

variable "vpc_id" {
  description = "VPC the endpoint belongs to (scopes the endpoint security groups)."
  type        = string

  validation {
    condition     = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
    error_message = "vpc_id must be a valid VPC ID (vpc-xxxxxxxx)."
  }
}

variable "server_certificate_arn" {
  description = "ACM ARN of the server certificate presented to connecting clients."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-zA-Z-]*:acm:", var.server_certificate_arn))
    error_message = "server_certificate_arn must be an ACM certificate ARN."
  }
}

variable "client_cidr_block" {
  description = "CIDR (>= /22) from which client IPs are assigned. Must NOT overlap any associated VPC/subnet CIDR."
  type        = string

  validation {
    condition     = can(cidrhost(var.client_cidr_block, 0)) && tonumber(split("/", var.client_cidr_block)[1]) <= 22
    error_message = "client_cidr_block must be a valid CIDR of /22 or larger (e.g. 10.250.0.0/22)."
  }
}

# ---- Authentication ----
variable "authentication_type" {
  description = "Auth method: certificate-authentication, directory-service-authentication, or federated-authentication."
  type        = string
  default     = "certificate-authentication"

  validation {
    condition = contains([
      "certificate-authentication",
      "directory-service-authentication",
      "federated-authentication",
    ], var.authentication_type)
    error_message = "authentication_type must be one of certificate-authentication, directory-service-authentication, or federated-authentication."
  }
}

variable "root_certificate_chain_arn" {
  description = "ACM ARN of the client root certificate chain (certificate-authentication only)."
  type        = string
  default     = null
}

variable "active_directory_id" {
  description = "AWS Directory Service directory ID (directory-service-authentication only)."
  type        = string
  default     = null
}

variable "saml_provider_arn" {
  description = "IAM SAML provider ARN for federated auth (federated-authentication only)."
  type        = string
  default     = null
}

variable "self_service_saml_provider_arn" {
  description = "IAM SAML provider ARN used by the self-service portal (federated-authentication only)."
  type        = string
  default     = null
}

# ---- Connectivity / behaviour ----
variable "subnet_ids" {
  description = "Subnets to associate (ideally one per AZ). Each association is billed per hour."
  type        = list(string)
  default     = []
}

variable "split_tunnel" {
  description = "true = only routes you authorize go over the VPN; false = all client traffic (incl. internet) tunnels."
  type        = bool
  default     = true
}

variable "transport_protocol" {
  description = "VPN transport protocol: udp (recommended) or tcp."
  type        = string
  default     = "udp"

  validation {
    condition     = contains(["udp", "tcp"], var.transport_protocol)
    error_message = "transport_protocol must be 'udp' or 'tcp'."
  }
}

variable "vpn_port" {
  description = "Port the endpoint listens on: 443 or 1194."
  type        = number
  default     = 443

  validation {
    condition     = contains([443, 1194], var.vpn_port)
    error_message = "vpn_port must be 443 or 1194."
  }
}

variable "dns_servers" {
  description = "DNS server IPs pushed to clients (e.g. the VPC .2 resolver). Empty uses device defaults."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.dns_servers) <= 2
    error_message = "At most two DNS servers may be specified."
  }
}

variable "security_group_ids" {
  description = "Security groups applied to the endpoint's target network associations. Empty uses the VPC default SG."
  type        = list(string)
  default     = []
}

variable "session_timeout_hours" {
  description = "Maximum VPN session duration before re-auth: 8, 10, 12, or 24 hours."
  type        = number
  default     = 24

  validation {
    condition     = contains([8, 10, 12, 24], var.session_timeout_hours)
    error_message = "session_timeout_hours must be 8, 10, 12, or 24."
  }
}

variable "self_service_portal" {
  description = "Enable the self-service portal where users download their client config (federated/AD auth)."
  type        = bool
  default     = false
}

# ---- Authorization rules ----
variable "authorization_rules" {
  description = <<-EOT
    Destination CIDRs clients are allowed to reach. access_group_id scopes a rule
    to a single AD/SAML group SID; null authorizes all groups. At least one rule is
    required for any connectivity.
  EOT
  type = list(object({
    target_network_cidr = string
    access_group_id     = optional(string)
    description         = optional(string, "Managed by Terraform")
  }))
  default = []
}

# ---- Additional routes (split-tunnel egress) ----
variable "additional_routes" {
  description = <<-EOT
    Extra Client VPN routes for split-tunnel egress beyond an associated subnet
    (e.g. 0.0.0.0/0 for full internet, a peered VPC, or an on-prem range via TGW).
    target_subnet_id must be one of the associated subnet_ids.
  EOT
  type = list(object({
    destination_cidr_block = string
    target_subnet_id       = string
    description           = optional(string, "Managed by Terraform")
  }))
  default = []
}

# ---- Connection logging ----
variable "connection_logging_enabled" {
  description = "Stream client connection logs to CloudWatch Logs."
  type        = bool
  default     = true
}

variable "cloudwatch_log_group_name" {
  description = "CloudWatch Logs group name for connection logs (required when connection_logging_enabled = true)."
  type        = string
  default     = null
}

variable "cloudwatch_log_stream_name" {
  description = "Optional CloudWatch Logs stream name; AWS auto-creates one when null."
  type        = string
  default     = null
}

# ---- Optional client-side extras ----
variable "client_login_banner_text" {
  description = "Optional banner (<= 1400 chars) shown to users on connect; null disables it."
  type        = string
  default     = null

  validation {
    condition     = var.client_login_banner_text == null || length(var.client_login_banner_text) <= 1400
    error_message = "client_login_banner_text must be 1400 characters or fewer."
  }
}

variable "client_connect_lambda_arn" {
  description = "Optional Lambda ARN (name must start with AWSClientVPN-) run on connect for device posture checks; null disables it."
  type        = string
  default     = null
}

variable "tags" {
  description = "Additional tags merged onto the endpoint."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "ID of the Client VPN endpoint."
  value       = aws_ec2_client_vpn_endpoint.this.id
}

output "arn" {
  description = "ARN of the Client VPN endpoint."
  value       = aws_ec2_client_vpn_endpoint.this.arn
}

output "dns_name" {
  description = "DNS name to embed in the .ovpn client profile (prefix with a random label to use the wildcard cert)."
  value       = aws_ec2_client_vpn_endpoint.this.dns_name
}

output "self_service_portal_url" {
  description = "Self-service portal URL where users download their config (empty if disabled)."
  value       = aws_ec2_client_vpn_endpoint.this.self_service_portal_url
}

output "security_group_ids" {
  description = "Security groups currently associated with the endpoint."
  value       = aws_ec2_client_vpn_endpoint.this.security_group_ids
}

output "association_ids" {
  description = "Map of subnet_id => network association ID."
  value       = { for s, a in aws_ec2_client_vpn_network_association.this : s => a.id }
}

output "authorization_rule_ids" {
  description = "IDs of the created authorization rules."
  value       = [for r in aws_ec2_client_vpn_authorization_rule.this : r.id]
}

How to use it

A common production pattern: SAML/Entra-federated access, split-tunnel, two-AZ associations, per-group authorization to the data and app subnets, and connection logging.

resource "aws_cloudwatch_log_group" "client_vpn" {
  name              = "/aws/clientvpn/prod-remote-access"
  retention_in_days = 90
}

module "client_vpn" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-client-vpn?ref=v1.0.0"

  name        = "prod-remote-access"
  description = "Remote engineer access into prod VPC"
  vpc_id      = aws_vpc.prod.id

  # Server cert from ACM; client CIDR must not overlap the VPC (10.0.0.0/16 here).
  server_certificate_arn = aws_acm_certificate.vpn_server.arn
  client_cidr_block      = "10.250.0.0/22"

  # Federated auth via an IAM SAML provider fed from Entra ID / IAM Identity Center.
  authentication_type            = "federated-authentication"
  saml_provider_arn              = aws_iam_saml_provider.entra.arn
  self_service_saml_provider_arn = aws_iam_saml_provider.entra_self_service.arn
  self_service_portal            = true

  # Associate one private subnet per AZ.
  subnet_ids = [
    aws_subnet.private_a.id,
    aws_subnet.private_b.id,
  ]

  # Split-tunnel: only the authorized CIDRs go over the VPN.
  split_tunnel = true
  dns_servers  = ["10.0.0.2"] # VPC resolver

  security_group_ids = [aws_security_group.client_vpn.id]

  # Per-group network segmentation by SAML group SID.
  authorization_rules = [
    {
      target_network_cidr = "10.0.10.0/24" # app subnets
      access_group_id     = "platform-engineers"
      description         = "Platform team -> app tier"
    },
    {
      target_network_cidr = "10.0.20.0/24" # data subnets
      access_group_id     = "dbas"
      description         = "DBAs -> data tier"
    },
  ]

  # Connection logging to CloudWatch.
  connection_logging_enabled = true
  cloudwatch_log_group_name  = aws_cloudwatch_log_group.client_vpn.name

  client_login_banner_text = "Authorized use only. Activity is logged."

  tags = {
    Environment = "prod"
    Owner       = "platform-team"
  }
}

# Downstream reference: allow the Client VPN clients into the database tier by
# referencing the endpoint security group, and surface the connect URL.
resource "aws_security_group_rule" "db_from_vpn" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  source_security_group_id = one(module.client_vpn.security_group_ids)
  security_group_id        = aws_security_group.database.id
  description              = "PostgreSQL from Client VPN endpoint"
}

output "vpn_self_service_url" {
  description = "Where engineers download their VPN profile."
  value       = module.client_vpn.self_service_portal_url
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/client_vpn/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-client-vpn?ref=v1.0.0"
}

inputs = {
  name = "..."
  vpc_id = "..."
  server_certificate_arn = "..."
  client_cidr_block = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/client_vpn && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Logical name, used as the Name tag (1–255 chars).
description string "Managed by Terraform" No Free-text endpoint description.
vpc_id string Yes VPC the endpoint belongs to (validated as vpc-…).
server_certificate_arn string Yes ACM ARN of the server certificate presented to clients.
client_cidr_block string Yes Client IP pool (≥ /22); must not overlap any associated VPC/subnet.
authentication_type string "certificate-authentication" No certificate-, directory-service-, or federated-authentication.
root_certificate_chain_arn string null Conditional Client root cert chain ARN (certificate auth only).
active_directory_id string null Conditional Directory Service ID (AD auth only).
saml_provider_arn string null Conditional IAM SAML provider ARN (federated auth only).
self_service_saml_provider_arn string null No SAML provider ARN for the self-service portal (federated auth).
subnet_ids list(string) [] No Subnets to associate (one per AZ); each is billed per hour.
split_tunnel bool true No true = only authorized routes tunnel; false = all client traffic tunnels.
transport_protocol string "udp" No udp (recommended) or tcp.
vpn_port number 443 No Listener port: 443 or 1194.
dns_servers list(string) [] No Up to two DNS server IPs pushed to clients.
security_group_ids list(string) [] No Security groups on the target network associations.
session_timeout_hours number 24 No Max session duration: 8, 10, 12, or 24.
self_service_portal bool false No Enable the self-service config-download portal.
authorization_rules list(object) [] No Allowed destination CIDRs, optionally scoped to a group SID.
additional_routes list(object) [] No Extra split-tunnel routes (e.g. 0.0.0.0/0, peered VPC, on-prem).
connection_logging_enabled bool true No Stream connection logs to CloudWatch Logs.
cloudwatch_log_group_name string null Conditional Log group name (required when logging is enabled).
cloudwatch_log_stream_name string null No Optional log stream; AWS auto-creates one when null.
client_login_banner_text string null No On-connect banner (≤ 1400 chars); null disables it.
client_connect_lambda_arn string null No Lambda (name AWSClientVPN-…) for on-connect posture checks.
tags map(string) {} No Additional tags merged onto the endpoint.

Outputs

Name Description
id ID of the Client VPN endpoint.
arn ARN of the Client VPN endpoint.
dns_name DNS name for the .ovpn profile (prefix a random label to match the wildcard cert).
self_service_portal_url Self-service portal URL for config download (empty if disabled).
security_group_ids Security groups associated with the endpoint.
association_ids Map of subnet_id => network association ID.
authorization_rule_ids IDs of the created authorization rules.

Enterprise scenario

A fintech runs all internal tooling in private subnets with no public ingress and needs 300 engineers and contractors to reach it remotely. The platform team calls this module once per region with authentication_type = "federated-authentication" wired to Entra ID through IAM Identity Center, split_tunnel = true so only corporate CIDRs traverse the VPN (everything else exits the user’s local ISP), and a set of authorization_rules keyed on group SIDs so the SRE group reaches the 10.0.0.0/16 management space while contractors are scoped to a single 10.0.30.0/24 jump subnet. Connection logs stream to a per-environment CloudWatch Logs group the SOC ingests for session auditing, and the self_service_portal_url output is published so new joiners self-onboard their .ovpn profile without a ticket.

Best practices

TerraformAWSClient VPNModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading