This is a brief explanation of how to use Terraform to setup an AWS CVPN (Client VPN) where the certificates (for VPN authentication) are entirely generated and managed by AWS.

The advantage of using a managed certificates approach is that you need not generate or directly manage any certificates or private keys manually. To avoid such manual processes, this approach makes use of AWS Private CA (Certificate Authority) and AWS Certificate Manager. The main disadvantage of this approach is that AWS Private CA is an expensive proposition, at the time of writing, it is priced at $400 / Month / CA Certificate; we will use two CA Certificates (although you could just use one if you wish), at a total cost of $800 / Month.

If you want to avoid the cost of AWS Private CA, you can take an alternative approach where you manually provision and manage the certificates. You can find details of how to achieve that in my blog article - AWS Client VPN with Manually Provisioned Certificates using Terraform.

Chain of Trust

We will create a number of certificates that will form a chain of trust. Each subordinate certificate is signed by its parent certificate. Our chain of trust will look like:

  1. Root CA Certificate.
  2. CVPN Server Certificate - this endpoint certificate is for the CVPN server itself in AWS. This certificate is signed by the Root CA Certificate.
  3. CVPN Client CA Certificate - this CA certificate is used for issuing and signing certificates that are used by clients (e.g. users) to connect to the CVPN Server. This certificate is also signed by the Root CA Certificate.
  4. CVPN Root Client Certificate - this endpoint certificate is for configuring the CVPN server with a client certificate. This certificate is signed by the CVPN Client CA Certificate. This certificate is needed due to a peculiarity with how AWS Client VPN is configured in AWS; their CVPN Server configuration requires a client certificate so that it can access the chain-of-trust for the client certificates (e.g. Client CA Certificate and Root CA certificate)! No client (e.g. user) will actually ever use this certificate directly.
  5. CVPN Client Certificate - we create one of these endpoint certificates for each client (e.g. user) that wishes to connect to the CPVN Server. These certificates will also be signed by the CVPN Client CA Certificate.
Chain of Trust diagram

The purpose of having a separate Root CA and CVPN Client CA is that we have delegated trust from the Root to the CVPN. We can therefore easily isolate the CVPN if needed and manage CVPN certificate issuance and revocation completely separately to the Root. In future it may be desirable to create further delegated CAs from the Root CA for other purposes.

Creating the Certificates in Terraform

We can create all of the CA certificates and endpoint certificates, and store them in AWS Private CA and AWS Certificate Manager by using Terraform.

Terraform Root CA

The Terraform code below will create a Root CA and certificate:

resource "aws_acmpca_certificate_authority" "root_ca" {
  type = "ROOT"

  certificate_authority_configuration {
    key_algorithm     = "RSA_4096"
    signing_algorithm = "SHA512WITHRSA"

    subject {
      common_name         = "root.ca.cert.private.mydomain.com"
      organizational_unit = "DevOps"
      organization        = "My Company"
      locality            = "My City"
      state               = "My State"
      country             = "GB"
    }
  }

  tags = {
    Name        = "certificate_authority"
    Environment = "cvpn"
  }
}

resource "aws_acmpca_certificate" "root_ca_certificate" {
  certificate_authority_arn   = aws_acmpca_certificate_authority.root_ca.arn
  certificate_signing_request = aws_acmpca_certificate_authority.root_ca.certificate_signing_request
  signing_algorithm           = "SHA512WITHRSA"

  template_arn = "arn:${data.aws_partition.current.partition}:acm-pca:::template/RootCACertificate/V1"

  validity {
    type  = "YEARS"
    value = 5
  }
}

resource "aws_acmpca_certificate_authority_certificate" "root_ca_certificate_association" {
  certificate_authority_arn = aws_acmpca_certificate_authority.root_ca.arn

  certificate       = aws_acmpca_certificate.root_ca_certificate.certificate
  certificate_chain = aws_acmpca_certificate.root_ca_certificate.certificate_chain
}
Terraform code for a Root CA with AWS Private CA

Note that the type of the aws_acmpca_certificate_authority resource is set to ROOT.

Terraform CVPN Server Certificate

The Terraform code below will create a certificate for use as the CVPN Server certificate:

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

resource "tls_cert_request" "cvpn_server_certificate_signing_request" {
  private_key_pem = tls_private_key.cvpn_server_certificate_private_key.private_key_pem

  subject {
    common_name         = "cvpn-server.cvpn.cert.private.mydomain.com"
    organizational_unit = "DevOps"
    organization        = "My Company"
    street_address      = ["My Street"]
    locality            = "My City"
    state               = "My State"
    country             = "GB"
    postal_code         = "XX1 2XX"
  }
}

resource "aws_acmpca_certificate" "cvpn_server_certificate" {
  certificate_authority_arn   = aws_acmpca_certificate_authority.root_ca.arn
  certificate_signing_request = tls_cert_request.cvpn_server_certificate_signing_request.cert_request_pem
  signing_algorithm           = "SHA512WITHRSA"
  validity {
    type  = "YEARS"
    value = 3
  }
}

resource "aws_acm_certificate" "cvpn_server_certificate" {
  private_key       = tls_private_key.cvpn_server_certificate_private_key.private_key_pem
  certificate_body  = aws_acmpca_certificate.cvpn_server_certificate.certificate
  certificate_chain = aws_acmpca_certificate.cvpn_server_certificate.certificate_chain

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name        = "certificate"
    Scope       = "cvpn_server"
    Environment = "cvpn"
  }
}
Terraform code for a CVPN Server certificate

Note that the certificate_authority_arn of the aws_acmpca_certificate resource is set to the ARN (Amazon Resource Name) of our previously created Root CA, i.e.: aws_acmpca_certificate_authority.root_ca.arn.

Terraform CVPN Client CA

The Terraform code below will create a CVPN Client CA and certificate delegated from the Root CA:

resource "aws_acmpca_certificate_authority" "cvpn_client_ca" {
  type = "SUBORDINATE"

  certificate_authority_configuration {
    key_algorithm     = "RSA_4096"
    signing_algorithm = "SHA512WITHRSA"

    subject {
      common_name         = "cvpn-client.ca.cert.private.mydomain.com"
      organizational_unit = "DevOps"
      organization        = "My Company"
      locality            = "My City"
      state               = "My State"
      country             = "GB"
    }
  }

  tags = {
    Name        = "certificate_authority"
    Environment = "cvpn"
  }
}

resource "aws_acmpca_certificate" "cvpn_client_ca_certificate" {
  certificate_authority_arn   = aws_acmpca_certificate_authority.root_ca.arn
  certificate_signing_request = aws_acmpca_certificate_authority.cvpn_client_ca.certificate_signing_request
  signing_algorithm           = "SHA512WITHRSA"

  template_arn = "arn:${data.aws_partition.current.partition}:acm-pca:::template/SubordinateCACertificate_PathLen0/V1"

  validity {
    type  = "YEARS"
    value = 3
  }
}

resource "aws_acmpca_certificate_authority_certificate" "cvpn_client_ca_certificate_association" {
  certificate_authority_arn = aws_acmpca_certificate_authority.cvpn_client_ca.arn

  certificate       = aws_acmpca_certificate.cvpn_client_ca_certificate.certificate
  certificate_chain = aws_acmpca_certificate.cvpn_client_ca_certificate.certificate_chain
}
Terraform code for a subordinate CVPN Client CA with AWS Private CA

Note that the type of the aws_acmpca_certificate_authority resource is set to SUBORDINATE, and that the certificate_authority_arn of the aws_acmpca_certificate resource is set to the ARN (Amazon Resource Name) of our previously created Root CA, i.e.: aws_acmpca_certificate_authority.root_ca.arn.

Terraform CVPN Root Client Certificate

The Terraform code below will create a CVPN Root Client Certificate. It will only be used as part of the CVPN Server configuration in AWS.

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

resource "tls_cert_request" "root_user_cvpn_client_certificate_signing_request" {
  private_key_pem = tls_private_key.root_user_cvpn_client_certificate_private_key.private_key_pem

  subject {
    common_name         = "root-user.cvpn.cert.private.mydomain.com"
    organizational_unit = "DevOps"
    organization        = "My Company"
    street_address      = ["My Street"]
    locality            = "My City"
    state               = "My State"
    country             = "GB"
    postal_code         = "XX1 2XX"
  }
}

resource "aws_acmpca_certificate" "root_user_cvpn_client_certificate" {
  certificate_authority_arn   = aws_acmpca_certificate_authority.cvpn_client_ca.arn
  certificate_signing_request = tls_cert_request.root_user_cvpn_client_certificate_signing_request.cert_request_pem
  signing_algorithm           = "SHA512WITHRSA"
  validity {
    type  = "YEARS"
    value = 1
  }
}

resource "aws_acm_certificate" "root_user_cvpn_client_certificate" {
  private_key       = tls_private_key.root_user_cvpn_client_certificate_private_key.private_key_pem
  certificate_body  = aws_acmpca_certificate.root_user_cvpn_client_certificate.certificate
  certificate_chain = aws_acmpca_certificate.root_user_cvpn_client_certificate.certificate_chain

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name        = "certificate"
    Scope       = "cvpn_server"
    Environment = "cvpn"
  }
}
Terraform code for a CVPN Root Client Certificate

Note that the certificate_authority_arn of the aws_acmpca_certificate resource is set to the ARN (Amazon Resource Name) of our previously created CVPN Client CA, i.e.: aws_acmpca_certificate_authority.cvpn_client_ca.arn.

Terraform CVPN Client Certificate(s)

The Terraform code below will create a CVPN Client Certificate that will enable a client (e.g. user) to connect to the CVPN Server. You may use this as a template and repeat it for as many clients as you need.

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

resource "tls_cert_request" "user_1_cvpn_client_certificate_signing_request" {
  private_key_pem = tls_private_key.user_1_cvpn_client_certificate_private_key.private_key_pem

  subject {
    common_name         = "user-1.cvpn.cert.private.mydomain.com"
    organizational_unit = "DevOps"
    organization        = "My Company"
    street_address      = ["My Street"]
    locality            = "My City"
    state               = "My State"
    country             = "GB"
    postal_code         = "XX1 2XX"
  }
}

resource "aws_acmpca_certificate" "user_1_cvpn_client_certificate" {
  certificate_authority_arn   = aws_acmpca_certificate_authority.cvpn_client_ca.arn
  certificate_signing_request = tls_cert_request.user_1_cvpn_client_certificate_signing_request.cert_request_pem
  signing_algorithm           = "SHA512WITHRSA"
  validity {
    type  = "YEARS"
    value = 1
  }
}

resource "aws_acm_certificate" "user_1_cvpn_client_certificate" {
  private_key       = tls_private_key.user_1_cvpn_client_certificate_private_key.private_key_pem
  certificate_body  = aws_acmpca_certificate.user_1_cvpn_client_certificate.certificate
  certificate_chain = aws_acmpca_certificate.user_1_cvpn_client_certificate.certificate_chain

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name        = "certificate"
    Scope       = "cvpn_client"
    Environment = "cvpn"
  }
}
Terraform code to produce a CVPN Client (e.g. User) certificate

Note that the certificate_authority_arn of the aws_acmpca_certificate resource is set to the ARN (Amazon Resource Name) of our previously created CVPN Client CA, i.e.: aws_acmpca_certificate_authority.cvpn_client_ca.arn.

Creating the Client VPN in Terraform

Now that we have the CA certificates and endpoint certificates in place, we can create and configure the AWS Client VPN by using Terraform.


resource "aws_ec2_client_vpn_endpoint" "cvpn" {
  description = "Client VPN"

  vpc_id = <YOUR VPC ID>

  client_cidr_block = "192.168.68.0/22"
  split_tunnel      = true

  server_certificate_arn = aws_acm_certificate.cvpn_server_certificate.arn

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

  self_service_portal = "disabled"

  security_group_ids = [
    module.cvpn_access_security_group.security_group_id
  ]

  tags = {
    Name        = "cvpn_endpoint"
    Environment = "cvpn"
  }
}

resource "aws_ec2_client_vpn_network_association" "cvpn" {
  count = 1

  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.cvpn.id
  subnet_id              = <ID OF YOUR IPv4 SUBNET WHERE THE CLIENT VPN WILL TERMINATE>

  lifecycle {
    // The issue why we are ignoring changes is that on every change
    // terraform screws up most of the cvpn assosciations
    // see: https://github.com/hashicorp/terraform-provider-aws/issues/14717
    ignore_changes = [subnet_id]
  }
}

resource "aws_ec2_client_vpn_authorization_rule" "cvpn_auth" {
  client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.cvpn.id
  target_network_cidr    = <YOUR IPv4 SUBNET WHERE THE CLIENT VPN WILL TERMINATE>
  authorize_all_groups   = true
}


module "cvpn_access_security_group" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "4.17.2"

  name        = "cvpn_access_security_group"
  description = "Security group for CVPN Access"

  vpc_id = <YOUR VPC ID>

  computed_ingress_with_cidr_blocks = [
    {
      description = "VPN TLS"
      from_port   = 443
      to_port     = 443
      protocol    = "udp"
      cidr_blocks = <YOUR IPv4 SUBNET WHERE THE CLIENT VPN WILL TERMINATE>
    }
  ]
  number_of_computed_ingress_with_cidr_blocks = 1

  computed_ingress_with_ipv6_cidr_blocks = [
    {
      description      = "VPN TLS (IPv6)"
      from_port        = 443
      to_port          = 443
      protocol         = "udp"
      ipv6_cidr_blocks = <YOUR IPv6 SUBNET WHERE THE CLIENT VPN WILL TERMINATE>
    }
  ]
  number_of_computed_ingress_with_ipv6_cidr_blocks = 1

  egress_with_cidr_blocks = [
    {
      description = "All"
      from_port   = -1
      to_port     = -1
      protocol    = -1
      cidr_blocks = "0.0.0.0/0"
    }
  ]

  egress_with_ipv6_cidr_blocks = [
    {
      description = "All (IPv6)"
      from_port   = -1
      to_port     = -1
      protocol    = -1
      cidr_blocks = "2001:db8::/64"
    }
  ]

  tags = {
    Name        = "sg_cvpn"
    Type        = "security_group"
    Environment = "cvpn"
  }
}
Terraform code for a Client VPN

The following placeholders in the code above need to be filled out with your own Terraform AWS configuration values:

  • <YOUR VPC ID> - the AWS ID of your VPC in which you wish to deploy the Client VPN.
  • <ID OF YOUR IPv4 SUBNET WHERE THE CLIENT VPN WILL TERMINATE> - the AWS ID of the IPv4 Subnet within your VPC where you wish VPN traffic to arrive to.
  • <YOUR IPv4 SUBNET WHERE THE CLIENT VPN WILL TERMINATE> -  the IPv4 Subnet CIDR address within your VPC where you wish VPN traffic to arrive to.
  • <YOUR IPv6 SUBNET WHERE THE CLIENT VPN WILL TERMINATE> - the IPv6 Subnet CIDR address within your VPC where you wish VPN traffic to arrive to.

Connecting to the Client VPN

Once you have the above setup you can download a skeleton OpenVPN configuration file from the AWS Dashboard.

Download OpenVPN Configuration file from AWS

The OpenVPN configuration file provided by AWS will be incomplete, and will something look like this:

client
dev tun
proto udp
remote cvpn-endpoint-006d2181ae8616b54.prod.clientvpn.eu-west-2.amazonaws.com 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verb 3
<ca>
-----BEGIN CERTIFICATE-----
SECRET STUFF HERE ;-)
-----END CERTIFICATE-----

</ca>


reneg-sec 0

verify-x509-name cvpn-server.cvpn.cert.private.mydomain.com name
AWS provided OpenVPN configuration file


You will need to modify it to add:

  1. A <cert> section containing the certificate for one of the CVPN Client Certificate(s) that you generated above.
  2. A <key> section containing the private key for the CVPN Client Certificate that you are using.

Note that the values needed for the <cert> and <key> can be found in your terraform.tfstate file. You should then have a complete OpenVPN configuration file that looks similar to this:

client
dev tun
proto udp
remote cvpn-endpoint-006d2181ae8616b54.prod.clientvpn.eu-west-2.amazonaws.com 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verb 3
<ca>
-----BEGIN CERTIFICATE-----
SECRET STUFF HERE ;-)
-----END CERTIFICATE-----

</ca>
<cert>
-----BEGIN CERTIFICATE-----
SECRET STUFF HERE ;-)
-----END CERTIFICATE-----

</cert>
<key>
-----BEGIN RSA PRIVATE KEY-----
SECRET STUFF HERE ;-)
-----END RSA PRIVATE KEY-----

</key>

reneg-sec 0

verify-x509-name cvpn-server.cvpn.cert.private.mydomain.com name
Complete OpenVPN configuration file

You may now use your favourite OpenVPN client tool, with the above OpenVPN configuration file to connect to your new Client VPN; I personally use Tunnelblick. Enjoy!