Replacing AWS Client VPN with Pritunl: A Terraform-First Approach

Replacing AWS Client VPN with Pritunl: A Terraform-First Approach

#aws #infrastructure as code #pritunl #terraform #vpn
Nico Aroyo
February 03, 2026

We were looking to replace our AWS Client VPN, mainly because of its high cost, and chose Pritunl as our new VPN solution.

The first time I deployed Pritunl, I followed the official documentation step by step: installing it on an EC2 instance, figuring out how to place it behind a load balancer, and configuring the Route53 records so the UI would be reachable in a sensible way.

All of this was done manually over SSH, and I then had to document it and repeat the exact setup when it was time to roll it out again in the next environment.

This was slow, repetitive work, and too easy to get slightly different in each environment.

I wanted a Terraform module that would take care of all of this for me: create the infrastructure, install and bootstrap Pritunl, and make the web UI available — in a way that is fast to apply, easy to review, doesn’t require SSH access to the EC2 and fully repeatable across environments.

The module I’ve created includes everything you need and lets you access the Pritunl UI.

What you’ll learn in this article

In this article, I’ll walk through:

  • Why we decided to migrate from AWS VPN to Pritunl
  • How we designed a fully self-hosted VPN stack on AWS

    (EC2 + NLB + CloudFront + WAF + Route53 + Secrets Manager)


  • The Terraform module that raises everything so the UI is ready right after terraform apply

Why move away from AWS VPN?

AWS Client VPN has some real advantages:

  • Fully managed, no instances to patch
  • Scales automatically with demand
  • Deep integration with AWS auth and networking

However, the pricing model is painful for growing teams:

  • You pay per-hour for each subnet association
  • You pay per-hour for each active client connection

Once we crossed a certain scale (more engineers, more subnets, more environments), the monthly bill was no longer aligned with how “simple” our VPN needs actually were.

We wanted a cheaper solution that would allow us to scale our team and environments for much less, so we tried Pritunl.

What is Pritunl?

Pritunl is an open source VPN server with a web UI that sits on top of OpenVPN and WireGuard. It’s designed for:

  • Remote user access
  • Site-to-site / VPC-to-VPC links
  • Hybrid and multi-cloud connectivity

Key points that made it attractive for us:

  • No per-user / per-connection pricing – paid plans are per host, not per seat
  • Supports OpenVPN and WireGuard, plus IPsec for site-to-site and VPC peering
  • Enterprise features like SSO on higher plans

Pricing:

  • Enterprise: 70$ per host per month – we chose enterprise for some security needs
  • Premium: 10$ per host per month
  • Additional resource costs (depends on the instance size you choose) we are using t3.medium and costs total at about 40$~ per month

Design goals for our self-hosted VPN

When we moved from AWS VPN to Pritunl, we set a few explicit goals:

  1. One terraform apply
    • Pritunl is installed and running
    • Admin UI available behind a nice DNS name
    • Setup key and default admin password automatically stored in Secrets Manager
  2. No public IP on the instance
    • EC2 instance sits in a private subnet
    • External access only through a Network Load Balancer (NLB) and CloudFront
  3. Security baked in
    • WAF on the CloudFront distribution
    • TLS everywhere (CloudFront + NLB)
    • Security groups locked down to the minimum needed
  4. Reusable across environments
    • Same module for dev, staging, prod
    • Just pass different tags, subnets, domains, and certificates

Let’s look at how the Terraform module implements that.

The Terraform module: from zero to VPN in one apply

The full module is posted below for your convenience

Variables.tf:

variable "name" {
  description = "Base name for all resources"
  type        = string
}

variable "ami_id" {
  description = "AMI ID to use for the EC2 instance (e.g., Amazon Linux 2023 x86_64)"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID"
  type        = string
}

variable "private_subnet_ids" {
  description = "Private subnet IDs for the EC2 instance"
  type        = list(string)
}

variable "public_subnet_ids" {
  description = "Public subnet IDs for the ALB/NLB"
  type        = list(string)
}

variable "certificate_arn" {
  description = "ACM certificate ARN for HTTPS on the ALB"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.medium"
}

variable "key_name" {
  description = "Optional EC2 key pair name"
  type        = string
  default     = null
}

variable "iam_instance_profile_name" {
  description = "Optional IAM instance profile name (e.g., with SSM permissions)"
  type        = string
  default     = null
}

variable "allowed_http_cidrs" {
  description = "CIDRs allowed to reach ALB on 80"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

variable "allowed_https_cidrs" {
  description = "CIDRs allowed to reach ALB on 443"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

variable "udp_allowed_cidrs" {
  description = "CIDRs allowed to reach UDP port (NLB preserves client IP)"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

variable "tags" {
  description = "Tags to add to all resources"
  type        = map(string)
  default     = {}
}

variable "region" {

}

variable "dns_zone_id" {
  description = "Route53 hosted zone ID for creating a DNS record (optional)"
  type        = string
}

variable "dns_name" {
  description = "Full DNS name for the NLB. Optional."
  type        = string
}

variable "environment" {
  description = "name of the environment e.g production"
  type        = string
}

variable "port" {
  description = "The port number to use for the server" 
  type        = number 
}

variable "waf_web_acl_arn" {
  description = "CLOUDFRONT-scoped WAF Web ACL ARN"
  type        = string
}

variable "acm_certificate_arn" {
  description = "ACM cert ARN"
  type        = string
}

variable "price_class" {
  description = "CloudFront price class"
  type        = string
  default     = "PriceClass_100"
}

1. Bootstrapping Pritunl admin credentials safely

First, we create a Secrets Manager secret for the Pritunl setup key and default admin password:

resource "aws_secretsmanager_secret" "pritunl_admin" {
  name        = "${var.name}-pritunl-admin-credentials"
  description = "Pritunl admin credentials for ${var.name}"
  tags        = var.tags

  # Optional: for non-prod we allow immediate deletion/recreation
  recovery_window_in_days = var.environment == "prod" ? 7 : 0
}

Then we give the EC2 instance permission to write to this specific secret:

resource "aws_iam_role" "pritunl_instance_role" {
  name = "${var.name}-instance-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })

  tags = var.tags
}

resource "aws_iam_policy" "pritunl_secrets_writer" {
  name        = "${var.name}-pritunl-secrets-writer"
  description = "Allow EC2 Pritunl instance to write its credentials to Secrets Manager"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:PutSecretValue"]
      Resource = aws_secretsmanager_secret.pritunl_admin.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "pritunl_secrets_attachment" {
  role       = aws_iam_role.pritunl_instance_role.name
  policy_arn = aws_iam_policy.pritunl_secrets_writer.arn
}

resource "aws_iam_instance_profile" "pritunl_instance_profile" {
  name = "${var.name}-instance-profile"
  role = aws_iam_role.pritunl_instance_role.name
}

The instance uses this role via an instance profile.

In user_data, once Pritunl is installed and started, we:

  • Ask Pritunl for the setup key
  • Ask Pritunl for the default admin credentials
  • Write them into the secret via the AWS CLI
resource "aws_instance" "this" {
  depends_on = [aws_secretsmanager_secret.pritunl_admin]

  ami                         = var.ami_id
  instance_type               = var.instance_type
  subnet_id                   = element(var.private_subnet_ids, 0)
  vpc_security_group_ids      = [aws_security_group.instance.id]
  associate_public_ip_address = false
  key_name                    = var.key_name
  iam_instance_profile        = aws_iam_instance_profile.pritunl_instance_profile.name

  # Disable only if you truly need packet forwarding
  source_dest_check = false

  user_data = <<-EOT
    #!/bin/bash
    set -euxo pipefail

    REGION="${var.region}"

    # MongoDB repo for Amazon Linux 2023
    cat >/etc/yum.repos.d/mongodb-org.repo <<'EOF'
    [mongodb-org]
    name=MongoDB Repository
    baseurl=https://repo.mongodb.org/yum/amazon/2023/mongodb-org/8.0/x86_64/
    gpgcheck=1
    enabled=1
    gpgkey=https://pgp.mongodb.com/server-8.0.asc
    EOF

    # Pritunl repo for Amazon Linux 2023
    cat >/etc/yum.repos.d/pritunl.repo <<'EOF'
    [pritunl]
    name=Pritunl Repository
    baseurl=https://repo.pritunl.com/stable/yum/amazonlinux/2023/
    gpgcheck=1
    enabled=1
    gpgkey=https://raw.githubusercontent.com/pritunl/pgp/master/pritunl_repo_pub.asc
    EOF

    # Install packages
    dnf -y update
    dnf -y install pritunl pritunl-openvpn wireguard-tools mongodb-org awscli

    # Configure local MongoDB for Pritunl
    cat >/etc/pritunl.conf <<'EOF'
    {
      "mongodb_uri": "mongodb://127.0.0.1:27017/pritunl"
    }
    EOF

    # Enable and start MongoDB & Pritunl
    systemctl enable --now mongod
    systemctl enable --now pritunl

    # Give services time to start
    sleep 30

    # Configure Pritunl web server to be behind NLB (HTTP only, TLS at NLB/CloudFront)
    pritunl set app.server_port 80
    pritunl set app.redirect_server false
    pritunl set app.server_ssl false
    pritunl set app.reverse_proxy true

    # Bump ulimit
    echo "* hard nofile 64000"    >> /etc/security/limits.conf
    echo "* soft nofile 64000"    >> /etc/security/limits.conf
    echo "root hard nofile 64000" >> /etc/security/limits.conf
    echo "root soft nofile 64000" >> /etc/security/limits.conf

    # Get setup key and default admin credentials
    SETUP_KEY="$(pritunl setup-key)"
    DEFAULT_CREDS="$(pritunl default-password)"

    # Build JSON payload safely
    SECRET_PAYLOAD="$(printf '{"'"setup_key"'":"'"%s"'" , "'"default_credentials"'":"'"%s"'"}' "$SETUP_KEY" "$DEFAULT_CREDS")"

    # Store them in Secrets Manager (secret created by Terraform)
    SECRET_ID="${aws_secretsmanager_secret.pritunl_admin.name}"

    aws secretsmanager put-secret-value \
      --region "$REGION" \
      --secret-id "$SECRET_ID" \
      --secret-string "$SECRET_PAYLOAD"
  EOT

  tags = merge(var.tags, { Name = "${var.name}-ec2" })
}

Result: after terraform apply, you can grab everything you need from Secrets Manager with no SSH and no copy-pasting from logs.

The EC2 resource also sets the Pritunl server port to 80, which is required for running Pritunl behind an NLB and CloudFront.

2. NLB and security groups

Security groups keep things tight:

  • EC2 Security Group
    • 80/tcp and VPN udp/<port> only from the NLB SG
resource "aws_security_group" "instance" {
  name        = "${var.name}-instance-sg"
  description = "Instance SG for ${var.name}"
  vpc_id      = var.vpc_id  

  # HTTP 80 from NLB client CIDRs
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    description     = "HTTP from NLB"
    security_groups = [aws_security_group.nlb.id]
  }

  # UDP port **only from NLB SG**
  ingress {
    from_port       = var.port
    to_port         = var.port
    protocol        = "udp"
    description     = "UDP ${var.port} from NLB"
    security_groups = [aws_security_group.nlb.id]
  }

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

  tags = merge(var.tags, { Name = "${var.name}-instance-sg" })
}
  • NLB Security Group
    • 80/tcp, 443/tcp, and udp/<port> from the internet
    • Open egres
resource "aws_security_group" "nlb" {
  name        = "${var.name}-nlb-sg"
  description = "NLB SG for ${var.name}"
  vpc_id      = var.vpc_id
  tags        = merge(var.tags, { Name = "${var.name}-nlb-sg" })
}

# Ingress to NLB from clients
resource "aws_security_group_rule" "nlb_ing_http" {
  type              = "ingress"
  security_group_id = aws_security_group.nlb.id
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  description       = "HTTP from clients"
}

resource "aws_security_group_rule" "nlb_ing_https" {
  type              = "ingress"
  security_group_id = aws_security_group.nlb.id
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  description       = "HTTPS from clients"
}

resource "aws_security_group_rule" "nlb_ing_udp_port" {
  type              = "ingress"
  security_group_id = aws_security_group.nlb.id
  from_port         = var.port
  to_port           = var.port
  protocol          = "udp"
  cidr_blocks       = ["0.0.0.0/0"]
  description       = "UDP ${var.port} from clients"
}

# Egress ALL from the NLB
resource "aws_security_group_rule" "nlb_egr_all" {
  type              = "egress"
  security_group_id = aws_security_group.nlb.id
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  description       = "Allow all egress from NLB"
}


########################################
# NLB for TLS 443, TCP 80, UDP 
########################################
resource "aws_lb" "edge" {
  name               = "${var.name}-nlb"
  internal           = false
  load_balancer_type = "network"
  subnets            = var.public_subnet_ids

  enable_cross_zone_load_balancing = true
  security_groups                  = [aws_security_group.nlb.id]

  tags = merge(var.tags, { Name = "${var.name}-nlb" })
}

# Target group for web traffic (app listens on 80)
resource "aws_lb_target_group" "web_http" {
  name        = "${var.name}-tg-http"
  port        = 80
  protocol    = "TCP"
  vpc_id      = var.vpc_id
  target_type = "instance"

  health_check {
    enabled             = true
    protocol            = "HTTP"
    port                = "80"
    path                = "/"
    healthy_threshold   = 2
    unhealthy_threshold = 2
    interval            = 30
    timeout             = 5
  }

  tags = merge(var.tags, { Name = "${var.name}-tg-http" })
}

resource "aws_lb_target_group_attachment" "web_http" {
  target_group_arn = aws_lb_target_group.web_http.arn
  target_id        = aws_instance.this.id
  port             = 80
}

# TLS 443 listener (TLS termination at NLB with ACM cert)
resource "aws_lb_listener" "tls_443" {
  load_balancer_arn = aws_lb.edge.arn
  port              = 443
  protocol          = "TLS"
  certificate_arn   = var.certificate_arn
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web_http.arn
  }
}

# Plain HTTP 80 listener
resource "aws_lb_listener" "tcp_80" {
  load_balancer_arn = aws_lb.edge.arn
  port              = 80
  protocol          = "TCP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web_http.arn
  }
}

# UDP port target group & listener
resource "aws_lb_target_group" "udp_port" {
  name        = "${var.name}-tg-udp-${var.port}"
  port        = var.port
  protocol    = "UDP"
  vpc_id      = var.vpc_id
  target_type = "instance"

  # HTTP health checks on :80
  health_check {
    enabled             = true
    protocol            = "HTTP"
    port                = "80"
    path                = "/"
    healthy_threshold   = 2
    unhealthy_threshold = 2
    interval            = 30
    timeout             = 10
  }

  tags = merge(var.tags, { Name = "${var.name}-tg-udp-${var.port}" })
}

resource "aws_lb_target_group_attachment" "udp_port" {
  target_group_arn = aws_lb_target_group.udp_port.arn
  target_id        = aws_instance.this.id
  port             = var.port
}

resource "aws_lb_listener" "udp_port" {
  load_balancer_arn = aws_lb.edge.arn
  port              = var.port
  protocol          = "UDP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.udp_port.arn
  }
}

The Network Load Balancer is internet-facing and preserves client IP for UDP:

resource "aws_lb" "edge" {
  name               = "${var.name}-nlb"
  internal           = false
  load_balancer_type = "network"
  subnets            = var.public_subnet_ids

  enable_cross_zone_load_balancing = true
  security_groups                  = [aws_security_group.nlb.id]

  tags = merge(var.tags, { Name = "${var.name}-nlb" })
}

We create:

  • A TCP target group listening on :80 for the web UI
  • A UDP target group on :port for the VPN traffic ( configurable via var.port)

3. CloudFront + WAF + Route53 in front of the UI

For the admin UI we wanted:

  • A nice URL like pritunl.demo
  • TLS termination in CloudFront
  • Protection from a CloudFront-scoped WAF Web ACL

So the module also provisions a CloudFront distribution with the NLB as origin:

resource "aws_cloudfront_distribution" "pritunl" {
  depends_on      = [aws_lb.edge]
  enabled         = true
  comment         = "Pritunl UI via CloudFront (${var.name})"
  price_class     = var.price_class
  web_acl_id      = var.waf_web_acl_arn
  is_ipv6_enabled = true
  http_version    = "http2"

  aliases = [var.dns_name]  

  origin {
    domain_name = aws_lb.edge.dns_name
    origin_id   = aws_lb.edge.dns_name

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    target_origin_id       = aws_lb.edge.dns_name
    viewer_protocol_policy = "redirect-to-https"

    # Dynamic app: allow all methods, no caching
    allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods  = ["GET", "HEAD"]

    min_ttl     = 0
    default_ttl = 0
    max_ttl     = 0

    forwarded_values {
      query_string = true
      headers      = ["*"]

      cookies {
        forward = "all"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = var.acm_certificate_arn  
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

Then a Route53 alias that maps the hostname to CloudFront:

data "aws_route53_zone" "route53_zone" {
  name         = "<your_route53_zone>"
  private_zone = false
}

resource "aws_route53_record" "pritunl_cf" {
  depends_on = [aws_cloudfront_distribution.pritunl]

  zone_id = data.aws_route53_zone.route53_zone.zone_id
  name    = var.dns_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.pritunl.domain_name
    zone_id                = aws_cloudfront_distribution.pritunl.hosted_zone_id
    evaluate_target_health = false
  }
}

From a user’s perspective, the flow is:

browser → CloudFront (TLS + WAF) → NLB → EC2 (Pritunl)

All of that is raised by the module.


4. Module interface and outputs

The module is meant to be dropped into any environment with just a handful of inputs, and it returns URLs you can use immediately.

We expose a couple of helpful outputs:

output "nlb_dns_name" {
  description = "DNS name of the Pritunl NLB"
  value       = aws_lb.edge.dns_name
}

output "pritunl_url" {
  description = "URL for accessing the Pritunl web UI"
  value       = var.dns_name != null ?
    "https://${var.dns_name}" :
    "https://${aws_lb.edge.dns_name}"
}

So after terraform apply, you need to perform the following steps in the UI:

  1. Go to pritunl_url (note that after apply it can take a few minutes for everything to finish setting up)
  2. Grab the setup key + default admin creds from Secrets Manager
  3. Finish initial setup in the UI
  4. Create an organization
  5. Create a server, use the port from the port variable you set for the module and attach to organization
  6. create your needed routes (usually your internal CIDR) – I recommend also deleting the 0.0.0.0 if you only want to access cloud resources (otherwise it will route all traffic from your PC through Pritunl)
  7. In hosts edit the host and put the DNS name of the NLB in Public Address and under advanced Sync Address
  8. Create a user and copy the profile URI
  9. Download the Pritunl client and import the profile using the URI from the previous step
  10. Test connection – Done!

No manual security group edits, no manual DNS, no copy-pasting from SSH sessions.

Summary

In this article I focused on the technical side of that migration and shared a complete, reusable Terraform module that:

  • Provisions an EC2 instance running Pritunl (with MongoDB on the same host) in a private subnet
  • Bootstraps Pritunl automatically and stores the setup key + default admin credentials in AWS Secrets Manager
  • Secures traffic with:
    • A Network Load Balancer for VPN UDP + web UI TCP
    • CloudFront in front of the NLB for the admin UI
    • A CloudFront-scoped WAF and TLS everywhere
  • Exposes a clean URL via Route53 and returns a ready-to-use pritunl_url output

In practice, this setup has made Pritunl a straightforward east to use tool to and has significantly reduced our monthly VPN costs saving us hundreds of dollars.

If you’re comfortable managing a small amount of infrastructure in exchange for flexibility and lower spend, Pritunl is absolutely worth considering.