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:
- 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
- No public IP on the instance
- EC2 instance sits in a private subnet
- External access only through a Network Load Balancer (NLB) and CloudFront
- Security baked in
- WAF on the CloudFront distribution
- TLS everywhere (CloudFront + NLB)
- Security groups locked down to the minimum needed
- Reusable across environments
- Same module for
dev,staging,prod - Just pass different tags, subnets, domains, and certificates
- Same module for
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
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/tcpand VPNudp/<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, andudp/<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
:80for the web UI - A UDP target group on
:portfor the VPN traffic ( configurable viavar.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:
- Go to
pritunl_url(note that after apply it can take a few minutes for everything to finish setting up) - Grab the setup key + default admin creds from Secrets Manager
- Finish initial setup in the UI
- Create an organization
- Create a server, use the port from the port variable you set for the module and attach to organization
- 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)
- In hosts edit the host and put the DNS name of the NLB in Public Address and under advanced Sync Address
- Create a user and copy the profile URI
- Download the Pritunl client and import the profile using the URI from the previous step
- 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_urloutput
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.