Insights

Building Client VPN on AWS with Terraform

Author
Kuba Czaplicki
Published
February 3, 2025
Last update
February 3, 2025

Table of Contents

Key Takeaways

  1. AWS Client VPN provides secure, certificate-based remote access to VPC resources, ideal for distributed teams working remotely.
  2. The implementation uses a three-tier certificate system (root, server, client) managed through AWS Certificate Manager for enhanced security and access control.
  3. Split tunneling can be enabled to optimize routing and reduce data transfer costs by only routing AWS-bound traffic through the VPN.
  4. The solution includes comprehensive logging via CloudWatch for audit and compliance purposes, tracking connection attempts, duration, and user activity.
  5. Security is maintained through proper subnet associations, authorization rules, and security group configurations, ensuring protected access to resources like RDS instances.

Is Your HealthTech Product Built for Success in Digital Health?

Download the Playbook

In this article, we’ll walk through implementing AWS Client VPN using Terraform. I also want to outline the issue of providing secure connections to your infrastructure, in this case AWS VPC, particularly when an entire company is working fully remotely, which is a common case nowadays.

If you want to jump to a ready-to-use module, check out the links in the Summary section.

Problem

Secure network connectivity is crucial when building modern systems, especially for organizations with distributed teams. While traditional site-to-site VPNs work well for connecting office locations, they don’t address the needs of remote employees working from different parts of the world with dynamic IP addresses.

I recently faced this challenge when implementing a VPN solution for a fully remote company that needed to access some parts of their infrastructure (DB read replicas, Superset for analytics) in a secure way due to compliance reasons. Each employee required secure VPC access regardless of their current IP address, so Site-to-Site VPN and connecting to the office was not an option.

The pattern that I found suitable for this case is Client VPN—a managed client-based VPN service that provides secure and encrypted access to on-premises networks and automatically scales up to the number of users.

It uses self-signed certificates for authentication instead of IP addresses and also provides TLS encryption. It allows company employees to access their VPC resources without exposing them publicly from any place they work in and manages access using tunnel connections efficiently.

Theory

AWS Client VPN uses a Client VPN endpoint to provide connection to VPC. The client establishes the VPN session from their local computer or mobile device using an OpenVPN-based VPN client application. After they have established the VPN session, they can securely access the resources in the VPC where the associated subnet is located.

Later, you can also implement Virtual Private Gateway, Site-to-Site VPN, and Customer Gateway to “pipe” this traffic to your on-premises network and treat VPC as a “proxy”.

Under the hood, AWS VPN Client enhances OpenVPN.

An in-depth look at how Client VPN works is as follows:

  • First, the Client resolves DNS of the Client VPN endpoint and then initiates contact
  • The Client initiates a UDP connection to the server and creates a virtual TUN interface for tunneled traffic
  • The Client and servers perform a TLS handshake, and when validated, both sides agree on encryption parameters
  • The TUN interface is configured with an IP address, routes are set up for tunneled traffic, and the connection is ready to use

Note that we use manual authentication that uses certificates to perform authentication between client and servers. Certificates are a digital form of identification issued by a certificate authority (CA). The server uses client certificates to authenticate clients when they attempt to connect to the Client VPN endpoint.

You can create a separate client certificate and key for each client that will connect to the Client VPN endpoint. This enables you to revoke a specific client certificate if a user leaves your organization. In this case, when you create the Client VPN endpoint, you can specify the server certificate ARN for the client certificate, provided that the client certificate has been issued by the same CA as the server certificate.

It is also possible to create custom logic for authorization by enhancing AWS Lambda functions.

Scheme

In this article, I focus on connecting to a single VPC, but when you need to connect to two or more VPCs, you can enhance VPC peering connection to do so. It is also possible to do Client to Client connection.

Note:

  • You need to create or identify a VPC with at least one subnet. Identify the subnet in the VPC to associate with the Client VPN endpoint and note its IPv4 CIDR ranges.
  • Identify a suitable CIDR range for the client IP addresses that does not overlap with the VPC CIDR.

Prerequisites

  • Terraform installed on your system (version 1.9 was used for this tutorial)
  • VPC with at least one subnet
  • AWS admin access credentials (access key and secret key) configured for authentication

Overview

Here is what we need to do:
1. Create certificates for root, client, and server and upload them into AWS Certificate Manager
2. Create a VPN Endpoint in the same Region as the VPC
3. Associate the subnet with the Client endpoint
4. Add an authorization rule to give clients access to the VPC
5. Add a rule to your resources’ security groups to allow traffic from the security group that was applied to the subnet association in step 2

Chapter 1: Certificates

First, we need to create a Root certificate that will be used to generate server and client certificates:

################################################################################
# CA Key and Certificate (Root CA)
################################################################################

resource "tls_private_key" "ca_key" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "tls_self_signed_cert" "ca_cert" {
  private_key_pem = tls_private_key.ca_key.private_key_pem

  subject {
    common_name  = "VPN Root CA"
    organization = var.organization_name
    country      = "US" # Added for compliance
  }

  validity_period_hours = 87600 # 10 years
  is_ca_certificate     = true

  allowed_uses = [
    "cert_signing",
    "crl_signing",
    "digital_signature",
    "key_encipherment"
  ]
}

Next, as mentioned, we generate server and client certificates and keys:

################################################################################
# VPN Server Key and Certificate
################################################################################

resource "tls_private_key" "vpn_key" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "tls_cert_request" "vpn_csr" {
  private_key_pem = tls_private_key.vpn_key.private_key_pem

  subject {
    common_name  = var.vpn_domain
    organization = var.organization_name
    country      = "US"
  }
}

resource "tls_locally_signed_cert" "vpn_cert" {
  cert_request_pem   = tls_cert_request.vpn_csr.cert_request_pem
  ca_private_key_pem = tls_private_key.ca_key.private_key_pem
  ca_cert_pem        = tls_self_signed_cert.ca_cert.cert_pem

  validity_period_hours = 8760 # 1 year

  allowed_uses = [
    "digital_signature",
    "key_encipherment",
    "server_auth",
    "client_auth",
  ]

  set_subject_key_id = true
}

################################################################################
# Client Key and Certificate
################################################################################

resource "tls_private_key" "client_key" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "tls_cert_request" "client_csr" {
  private_key_pem = tls_private_key.client_key.private_key_pem

  subject {
    common_name  = "client.${var.vpn_domain}"
    organization = var.organization_name
    country      = "US"
  }
}


resource "tls_locally_signed_cert" "client_cert" {
  cert_request_pem   = tls_cert_request.client_csr.cert_request_pem
  ca_private_key_pem = tls_private_key.ca_key.private_key_pem
  ca_cert_pem        = tls_self_signed_cert.ca_cert.cert_pem

  validity_period_hours = var.certificate_validity_period_hours

  allowed_uses = [
    "digital_signature",
    "key_encipherment",
    "client_auth",
  ]

  set_subject_key_id = true
}

Note: your state contains sensitive information and you should keep it safe. I strongly advise running your Terraform code from a pipeline and keeping the state isolated and accessible only to authorized users.


Lastly, we import Client and server certificates to AWS Certificate Manager:

################################################################################
# Import Certificates to ACM
################################################################################

resource "aws_acm_certificate" "vpn_cert" {
  private_key       = tls_private_key.vpn_key.private_key_pem
  certificate_body  = tls_locally_signed_cert.vpn_cert.cert_pem
  certificate_chain = tls_self_signed_cert.ca_cert.cert_pem

  tags = var.tags
}

resource "aws_acm_certificate" "ca_cert" {
  private_key      = tls_private_key.ca_key.private_key_pem
  certificate_body = tls_self_signed_cert.ca_cert.cert_pem

  tags = var.tags
}

Chapter 2: Client VPN Endpoint

The Client VPN endpoint is the resource that you create and configure to enable and manage client VPN sessions. It’s the termination point for all client VPN sessions.

################################################################################
# Create Client VPN Endpoint #
################################################################################

resource "aws_security_group" "vpn" {
  name_prefix = "client-vpn-endpoint-sg"
  description = "Security group for Client VPN endpoint"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "udp"
    cidr_blocks = var.allowed_cidr_blocks
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = var.tags
}

resource "aws_ec2_client_vpn_endpoint" "vpn" {
  description            = "Client VPN endpoint"
  server_certificate_arn = aws_acm_certificate.vpn_cert.arn
  client_cidr_block      = var.client_cidr_block
  vpc_id                 = var.vpc_id
  split_tunnel           = var.split_tunnel

  authentication_options {
    type                       = "certificate-authentication"
    root_certificate_chain_arn = aws_acm_certificate.ca_cert.arn
  }

  transport_protocol = "udp"
  security_group_ids = [aws_security_group.vpn.id]

  connection_log_options {
    enabled               = true
    cloudwatch_log_group  = aws_cloudwatch_log_group.vpn_logs.name
    cloudwatch_log_stream = aws_cloudwatch_log_stream.vpn_logs.name
  }

  dns_servers = ["169.254.169.253"]

  session_timeout_hours = 8

  client_login_banner_options {
    enabled     = true
    banner_text = "This VPN is for authorized users only. All activities may be monitored and recorded."
  }

  tags = merge(var.tags, {
    Name = "client-vpn-${var.vpn_domain}"
  })
}

As mentioned in steps 3 and 4, we need to associate the subnet and add authorization rules:

resource "aws_ec2_client_vpn_network_association" "vpn_subnet" {
  for_each               = toset(var.subnet_ids)
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id
  subnet_id              = each.value
}

resource "aws_ec2_client_vpn_authorization_rule" "vpn_auth_rule" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id
  target_network_cidr    = var.target_network_cidr
  authorize_all_groups   = true
}

For audit and compliance purposes, we need to implement logging:

################################################################################
# Logging
################################################################################

resource "aws_cloudwatch_log_group" "vpn_logs" {
  # encrypted by default
  name              = "/aws/vpn/${var.vpn_domain}"
  retention_in_days = 2192 # 6 years

  tags = var.tags
}

resource "aws_cloudwatch_log_stream" "vpn_logs" {
  name           = "vpn-connection-logs"
  log_group_name = aws_cloudwatch_log_group.vpn_logs.name
}

Using this logging, we collect information about connection attempts, who connected to VPN, when and for how long, from which IP, and more.

{
 "connection-log-type": "connection-attempt",
 "connection-attempt-status": "successful",
 "connection-attempt-failure-reason": "NA",
 "connection-id": "cvpn-connection-123",
 "client-vpn-endpoint-id": "cvpn-endpoint-123",
 "transport-protocol": "udp",
 "connection-start-time": "2025–01–14 16:41:57",
 "connection-last-update-time": "2025–01–14 16:41:57",
 "client-ip": "10.100.0.163",
 "common-name": "client.vpn.example.com",
 "device-type": "mac",
 "device-ip": "123.123.123.123",
 "port": "1234",
 "ingress-bytes": "0",
 "egress-bytes": "0",
 "ingress-packets": "0",
 "egress-packets": "0",
 "connection-end-time": "NA",
 "connection-duration-seconds": "0"
}

Split tunneling

When setting up the Client VPN Endpoint, we used this option:

split_tunnel = var.split_tunnel

By default, when you have a Client VPN endpoint, all traffic from clients is routed over the Client VPN tunnel.
When you enable split-tunnel on the Client VPN endpoint, we push the routes from the Client VPN endpoint route table to the device that is connected to the Client VPN endpoint.

This ensures that only traffic with a destination to the network matching a route from the Client VPN endpoint route table is routed over the Client VPN tunnel.

You can use a split-tunnel Client VPN endpoint when you do not want all user traffic to route through the Client VPN endpoin

Split-tunnel on Client VPN endpoints offers the following benefits:

  • You can optimize the routing of traffic from clients by having only AWS-destined traffic traverse the VPN tunnel
  • You can reduce the volume of outgoing traffic from AWS, thereby reducing the data transfer cost
  • Access public internet while being connected to AWS Client VPN

Chapter 3: Client Configuration and Access

We need to generate an OpenVPN config so we can provide it to VPN users. Let’s automate it:

################################################################################
# Generate Client VPN Config File
################################################################################

data "aws_ec2_client_vpn_endpoint" "selected" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id

  depends_on = [
    aws_ec2_client_vpn_endpoint.vpn
  ]
}

resource "local_file" "vpn_config" {
  filename = "${path.root}/client.ovpn"
  content  = <<-EOT
client
dev tun
proto udp
remote ${aws_ec2_client_vpn_endpoint.vpn.dns_name} 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verify-x509-name ${var.vpn_domain} name
reneg-sec 0
verb 3

<ca>
${tls_self_signed_cert.ca_cert.cert_pem}
</ca>

<cert>
${tls_locally_signed_cert.client_cert.cert_pem}
</cert>

<key>
${tls_private_key.client_key.private_key_pem}
</key>
EOT

  file_permission = "0600"

  depends_on = [
    aws_ec2_client_vpn_endpoint.vpn,
    tls_locally_signed_cert.client_cert,
    tls_private_key.client_key,
    tls_self_signed_cert.ca_cert
  ]
}

The output of this Terraform module is a client.ovpn configuration that will be passed to end users. Treat it as your login and password, as ANYONE with this configuration will be able to access the protected VPC.

When running Terraform on a CI/CD pipeline, you keep it as an artifact, and when running locally, you can just access this file after terraform apply

The file, named client.ovpn, contains the following components:

  • CA certificate
  • Client certificate
  • Client key
  • OpenVPN configuration

To establish the connection, you can either use the AWS VPN Client application or the OpenVPN client. To configure the AWS VPN Client application, follow these steps:

File -> 
Manage Profiles ->
Add Profile ->
Fill a name and add path to the client.ovpn file ->
Add profile

Then pick the profile and click connect. If the connection is successful, you will see ‘Connected’ status.

Chapter 4: Testing

Let’s say an end user wants to access an RDS database from their local machine. We don’t want to expose the RDS endpoint publicly, but we can easily achieve this task using Client VPN. To enable this, we need to update the RDS (or other resources like EC2) security group to allow access from the VPN tunnel.

resource "aws_security_group_rule" "rds_vpn_access" {
 type = "ingress"
 from_port = 5432
 to_port = 5432
 protocol = "tcp"
 security_group_id = "sg-123123" # your RDS security group
 source_security_group_id = module.vpn.security_group_id
 description = "Allow VPN clients to access RDS"
}

Now test connection without connecting to VPN:

psql -h database-1.123.<region>.rds.amazonaws.com -p 5432 -U postgres -d postgres
psql: error: connection to server at "database-1.123.<region>.rds.amazonaws.com" (123.123.123.123), port 5432 failed: Operation timed out
Is the server running on that host and accepting TCP/IP connections?

Assuming you followed Chapter 3 steps and managed successfully connect to VPN you should access your test RDS from local machine:

psql -h database-1.123.<region>.rds.amazonaws.com -p 5432 -U postgres -d postgres
Password for user postgres:
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
postgres=>

Summary

We’ve covered a small part of AWS’s available tools regarding VPN, but I find this one quite straightforward, and it works really well in production environments. I hope it also helps to understand how VPN works in general.

For a ready-to-use module implementation, check out the Momentum’s HealthStack repository that I am currently building!

So You're Looking to Implement Secure Remote Access?

At Momentum, we understand the challenges of managing secure infrastructure access for distributed teams. Whether you're implementing Client VPN, exploring other connectivity solutions, or building a comprehensive security architecture, our team of cloud experts is here to help. We specialize in designing and implementing scalable, secure, and compliant infrastructure solutions that meet your organization's unique needs. Let's transform your infrastructure challenges into robust, production-ready solutions. Contact us today to learn more about how we can help secure your cloud infrastructure!

Stay ahead in HealthTech. Subscribe for exclusive industry news, insights, and updates.

Be the first to know about newest advancements, get expert insights, and learn about leading  trends in the landscape of health technology. Sign up for our HealthTech Newsletter for your dose of news.

Oops, something went wrong
Your message couldn't come through. The data you provided seems to be insufficient or incorrect. Please make sure everything is in place and try again.

Read more

How to Implement SMART-on-FHIR with AWS HealthLake Using Terraform

|
January 10, 2025

Ensuring Security and Compliance for AI-Driven Health Bots

Filip Begiełło
|
December 3, 2024

Data Security in HealthTech: Essential Measures for Protecting Patient Information

Paulina Kajzer-Cebula
|
November 28, 2024

Understanding HIPAA: A Comprehensive Guide for HealthTech Startups

Patryk Iwaszkiewicz
|
November 27, 2024

Let's Create the Future of Health Together

Looking for a partner who not only understands your challenges but anticipates your future needs? Get in touch, and let’s build something extraordinary in the world of digital health.

Kuba Czaplicki