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:
- The endpoint itself —
client_cidr_block(the pool VPN clients draw from, must not overlap any associated VPC), aserver_certificate_arnfrom ACM, anauthentication_optionsblock (mutual-cert, Active Directory via Directory Service, or federated SAML),connection_log_options,split_tunnel, DNS servers, transport protocol, and the session timeout. - Network associations (
aws_ec2_client_vpn_network_association) — one per subnet. Each association is what actually places the endpoint into a VPC/AZ and bills per hour, so the subnet count is a direct cost lever. - Authorization rules (
aws_ec2_client_vpn_authorization_rule) — an explicit allow-list of destination CIDRs; without a rule, connected clients can reach nothing. Rules can be scoped to an AD/SAML group SID for per-group access. - Routes (
aws_ec2_client_vpn_route) — needed for split-tunnel egress beyond the directly-associated subnet (e.g. an internet path, a peered VPC, or an on-prem range via a Transit Gateway).
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
- You need remote engineer/employee access into private subnets (databases, internal tooling, bastion-free SSH/RDP) without exposing services publicly or maintaining a self-managed OpenVPN/WireGuard fleet.
- You want authentication tied to your identity provider — Active Directory groups via AWS Directory Service, or SAML federation (Okta, Entra ID, Google) through IAM Identity Center — instead of distributing static client certificates to everyone.
- You are standardising VPN access across many accounts/VPCs and want one audited module call (consistent logging, split-tunnel, timeouts) rather than copy-pasted endpoint + association + rule blocks.
- You need per-group network segmentation — e.g. the DBA group reaches the data subnets, the platform group reaches the management subnets — expressed as authorization rules keyed on group SIDs.
- Reach for something else if you need site-to-site network joins (use
aws_vpn_connection), an agentless clientless web portal (Client VPN requires the AWS VPN client / any OpenVPN client), or a full SASE/SD-WAN mesh (third-party or AWS Cloud WAN).
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 config — live/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 config — live/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
- Prefer federated (SAML) or AD auth over mutual certificates. Distributing and revoking per-user client certs at scale is painful; federation ties VPN access to your IdP lifecycle (disable the user, lose the VPN) and unlocks per-group authorization rules for least-privilege network access.
- Keep
split_tunnel = truefor cost and UX. Full-tunnel routes all user traffic — including their Netflix — through your associated subnets, inflating data-processing and NAT egress charges; split-tunnel sends only your authorized CIDRs over the VPN. Only choose full-tunnel when an inspection/DLP requirement forces it. - Authorize narrowly, never
0.0.0.0/0to everyone. A Client VPN with no authorization rules reaches nothing, and a single all-groups rule to0.0.0.0/0is the other extreme — scope rules to the specific destination CIDRs each group needs, and lock the endpoint security group to the ports/sources you actually serve. - Mind the per-association hourly cost. You pay per associated subnet-hour plus per active client-connection-hour. Associate one subnet per AZ for resilience — but no more than the AZs you genuinely serve — and remember the endpoint keeps billing even with zero connected users while associations exist.
- Size
client_cidr_blockdeliberately and keep it disjoint. It must be/22or larger and must not overlap any associated VPC, peered VPC, or on-prem range, or routing breaks; reserve a dedicated, well-documented block (e.g.10.250.0.0/22) per region up front. - Enable connection logging and a login banner from day one, and set a sane
session_timeout_hours. CloudWatch connection logs are the only practical record of who connected from where; pair them with an on-connect banner for AUP/legal notice and a 8–12h timeout so forgotten sessions force periodic re-authentication.