
Learn about securing SSH connections on RHEL 9 and CentOS 9 with Ansible roles. This guide covers key SSH security practices, Ansible playbook setup, and
Learn how to automate OpenVPN installation with Ansible roles on CentOS 9. This step-by-step guide helps you organize your playbooks, making your VPN setup modular, reusable, and scalable.
As network security becomes more crucial in today’s digital age, Virtual Private Networks (VPNs) like OpenVPN are widely used to ensure secure, encrypted connections over the internet. The process of installing and configuring OpenVPN manually can be cumbersome, particularly for system administrators handling multiple servers. Luckily, Ansible, a powerful IT automation tool, allows you to automate these processes. By structuring your OpenVPN installation as Ansible roles, you can achieve a modular, reusable, and scalable solution that ensures consistency and efficiency.
In this guide, we’ll walk you through automating the OpenVPN server installation on CentOS 9 using Ansible roles. By the end of this post, you’ll have a clear understanding of how to structure and deploy roles, making your setup more maintainable and easier to scale.
Before diving into the OpenVPN automation process, let’s briefly discuss Ansible roles. A role in Ansible is a way of organizing playbooks into reusable components. It allows you to break down your playbooks into smaller, modular units, each responsible for a specific task. Roles are defined in directories and can contain tasks, variables, handlers, templates, and files, making them incredibly flexible for a wide range of use cases.
Using roles allows you to:
|
|
|
Automating the OpenVPN installation with Ansible roles offers several advantages:
|
|
|
|
To follow along with this guide, you’ll need:
|
|
|
If you haven’t installed Ansible yet, you can do so using the following commands:
# Install EPEL repository
sudo dnf install epel-release -y
# Install Ansible
sudo dnf install ansible -y
Check if Ansible was successfully installed:
ansible --version
ansible [core 2.14.18]
config file = /etc/ansible/ansible.cfg
configured module search path = ['/home/admin/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/lib/python3.9/site-packages/ansible
ansible collection location = /home/admin/.ansible/collections:/usr/share/ansible/collections
executable location = /usr/bin/ansible
python version = 3.9.23 (main, Jun 27 2025, 00:00:00) [GCC 11.5.0 20240719 (Red Hat 11.5.0-7)] (/usr/bin/python3)
jinja version = 3.1.2
libyaml = True
Let’s start by setting up the Ansible roles directory structure. Ansible automatically recognizes roles in a specific directory structure. You can create it manually or use ansible-galaxy
to initialize the structure for you.
🔹Create your project folder |
mkdir -p openvpn-automation/roles
cd openvpn-automation
🔹Generate the Ansible role |
ansible-galaxy init openvpn
- Role openvpn was created successfully
This will create the following directory structure:
openvpn/
├── defaults
│ └── main.yml
├── files
├── handlers
│ └── main.yml
├── meta
│ └── main.yml
├── README.md
├── tasks
│ └── main.yml
├── templates
├── tests
│ ├── inventory
│ └── test.yml
└── vars
└── main.yml
8 directories, 8 files
Each of these subdirectories serves a specific purpose:
|
|
|
|
|
|
|
|
For this automation, we’ll focus mainly on the tasks, templates, and vars directories.
Let’s define the required variables that the OpenVPN role will use. We’ll store them in the vars/main.yml
file.
🔹Navigate to the vars directory |
cd roles/openvpn/vars
🔹Append the |
vim main.yml
Add the following content (below) to the file. Replace openvpn_server_ip
, openvpn_server_fqdn
, and openvpn_client_user
to the preferred names in your environment.
---
# vars file for openvpn
openvpn_version: "3.0"
easy_rsa_version: "3.0"
openvpn_server_ip: 172.105.3.120
openvpn_server_fqdn: openvpn.dev.naijalabs.net
openvpn_client_user: vpnuser01
openvpn_port: 1194
openvpn_proto: udp
Here’s some of what we’ve defined:
|
|
|
|
🔹Navigate to the defaults directory |
cd roles/openvpn/defaults
🔹Append the |
vim main.yml
---
# defaults file for openvpn - paths (can be overridden)
easy_rsa_path: "/etc/openvpn/easy-rsa"
client_configs_path: "/root/client-configs"
🔹Append the handlers directory |
vim roles/openvpn/handlers/main.yml
---
# handlers file for openvpn
- name: Restart OpenVPN
systemd:
name: openvpn-server@server
state: restarted
enabled: yes
Now let’s create the tasks that will install and configure OpenVPN on CentOS 9.
🔹Navigate to the tasks directory |
cd roles/openvpn/tasks
🔹Edit the |
vim main.yml
---
# tasks file for openvpn
- name: Install EPEL repository
dnf:
name: epel-release
state: present
- name: Install OpenVPN and Easy-RSA
dnf:
name:
- openvpn
- easy-rsa
state: present
- name: Ensure expect is installed
dnf:
name: expect
state: present
- name: Create Easy-RSA directory
file:
path: "{{ easy_rsa_path }}"
state: directory
mode: '0755'
- name: Copy Easy-RSA files
copy:
src: "/usr/share/easy-rsa/3/"
dest: "{{ easy_rsa_path }}/"
owner: root
group: root
mode: '0755'
remote_src: yes
- name: Initialize PKI
command: ./easyrsa init-pki
args:
chdir: "{{ easy_rsa_path }}"
creates: "{{ easy_rsa_path }}/pki"
- name: Check if CA cert exists
stat:
path: "{{ easy_rsa_path }}/pki/ca.crt"
register: ca_cert
- name: Build Certificate Authority (CA) non-interactively
shell: |
echo -e "{{ openvpn_server_fqdn }}\n" | ./easyrsa build-ca nopass
args:
chdir: "{{ easy_rsa_path }}"
when: not ca_cert.stat.exists
- name: Generate server certificate request and key non-interactively
shell: |
echo "{{ openvpn_server_fqdn }}" | ./easyrsa gen-req {{ openvpn_server_fqdn }} nopass
args:
chdir: "{{ easy_rsa_path }}"
creates: "{{ easy_rsa_path }}/pki/private/{{ openvpn_server_fqdn }}.key"
- name: Sign server certificate non-interactively
shell: |
echo "yes" | ./easyrsa sign-req server {{ openvpn_server_fqdn }}
args:
chdir: "{{ easy_rsa_path }}"
when: not lookup('file', easy_rsa_path + '/pki/issued/' + openvpn_server_fqdn + '.crt', errors='ignore')
- name: Generate client certificate request and key
shell: |
echo "yes" | ./easyrsa gen-req {{ openvpn_client_user }} nopass
args:
chdir: "{{ easy_rsa_path }}"
- name: Sign client certificate non-interactively
shell: |
echo "yes" | ./easyrsa sign-req client {{ openvpn_client_user }}
args:
chdir: "{{ easy_rsa_path }}"
- name: Generate Diffie-Hellman
command: ./easyrsa gen-dh
args:
chdir: "{{ easy_rsa_path }}"
creates: "{{ easy_rsa_path }}/pki/dh.pem"
- name: Generate TLS Auth Key
command: openvpn --genkey --secret ta.key
args:
chdir: "{{ easy_rsa_path }}/pki"
creates: "{{ easy_rsa_path }}/pki/ta.key"
- name: Create OpenVPN server directory
file:
path: /etc/openvpn/server
state: directory
- name: Copy PKI files to OpenVPN server
copy:
remote_src: yes
src: "{{ item.src }}"
dest: "{{ item.dest }}"
loop:
- { src: '{{ easy_rsa_path }}/pki/ca.crt', dest: '/etc/openvpn/server/ca.crt' }
- { src: '{{ easy_rsa_path }}/pki/issued/{{ openvpn_server_fqdn }}.crt', dest: '/etc/openvpn/server/{{ openvpn_server_fqdn }}.crt' }
- { src: '{{ easy_rsa_path }}/pki/private/{{ openvpn_server_fqdn }}.key', dest: '/etc/openvpn/server/{{ openvpn_server_fqdn }}.key' }
- { src: '{{ easy_rsa_path }}/pki/dh.pem', dest: '/etc/openvpn/server/dh.pem' }
- { src: '{{ easy_rsa_path }}/pki/ta.key', dest: '/etc/openvpn/server/ta.key' }
- name: Generate OpenVPN server configuration
template:
src: server.conf.j2
dest: /etc/openvpn/server/server.conf
notify: Restart OpenVPN
- name: Enable IP forwarding
copy:
dest: /etc/sysctl.d/99-openvpn.conf
content: |
net.ipv4.ip_forward=1
- name: Apply sysctl settings
command: sysctl --system
- name: Allow OpenVPN service
ansible.posix.firewalld:
service: openvpn
zone: public
permanent: true
immediate: yes
state: enabled
- name: Open OpenVPN port
ansible.posix.firewalld:
port: "{{ openvpn_port }}/{{ openvpn_proto }}"
zone: public
permanent: true
immediate: yes
state: enabled
- name: Enable IP masquerading
ansible.posix.firewalld:
masquerade: yes
zone: public
permanent: true
immediate: yes
state: enabled
- name: Reload firewalld
command: firewall-cmd --reload
- name: Create client config directory
file:
path: "{{ client_configs_path }}/files"
state: directory
mode: '0700'
- name: Slurp CA cert
slurp:
src: /etc/openvpn/server/ca.crt
register: ca_cert
- name: Slurp client cert
slurp:
src: "{{ easy_rsa_path }}/pki/issued/{{ openvpn_client_user }}.crt"
register: client_cert
- name: Slurp client key
slurp:
src: "{{ easy_rsa_path }}/pki/private/{{ openvpn_client_user }}.key"
register: client_key
- name: Slurp TLS auth key
slurp:
src: /etc/openvpn/server/ta.key
register: tls_auth
- name: Set decoded certs and keys
set_fact:
client_ovpn_ca: "{{ ca_cert.content | b64decode }}"
client_ovpn_cert: "{{ client_cert.content | b64decode }}"
client_ovpn_key: "{{ client_key.content | b64decode }}"
client_ovpn_ta: "{{ tls_auth.content | b64decode }}"
- name: Generate client base.conf
template:
src: base.conf.j2
dest: "{{ client_configs_path }}/{{ openvpn_client_user }}.ovpn"
owner: root
group: root
mode: '0600'
- name: Copy to final .ovpn
copy:
remote_src: yes
src: "{{ client_configs_path }}/{{ openvpn_client_user }}.ovpn"
dest: "{{ client_configs_path }}/files/{{ openvpn_client_user }}.ovpn"
- name: Start OpenVPN server
service:
name: openvpn-server@server
state: started
enabled: yes
Here are some of the tasks we’ve defined:
|
|
|
|
|
Now, let’s create a configuration file template that will be used by the OpenVPN server.
🔹Navigate to the templates directory |
cd roles/openvpn/templates
🔹Create a file named |
vim server.conf.j2
# server.conf.j2
port {{ openvpn_port }}
proto {{ openvpn_proto }}
proto udp6
dev tun
ca {{ easy_rsa_path }}/pki/ca.crt
cert {{ easy_rsa_path }}/pki/issued/{{ openvpn_server_fqdn }}.crt
key {{ easy_rsa_path }}/pki/private/{{ openvpn_server_fqdn }}.key
dh {{ easy_rsa_path }}/pki/dh.pem
tls-auth {{ easy_rsa_path }}/pki/ta.key 0
topology subnet
server 10.8.0.0 255.255.255.0
server-ipv6 2001:db8:0:123::/64
push "redirect-gateway def1 bypass-dhcp"
push "route-ipv6 2000::/3"
push "dhcp-option DNS 8.8.8.8"
push "dhcp-option DNS 1.1.1.1"
client-to-client
keepalive 10 120
cipher AES-256-CBC
auth SHA256
user nobody
group nobody
persist-key
persist-tun
status openvpn-status.log
verb 3
🔹Create a file named |
vim base.conf.j2
# base.conf.j2
client
dev tun
proto {{ openvpn_proto }}
remote {{ openvpn_server_ip }} {{ openvpn_port }}
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-CBC
auth SHA256
key-direction 1
verb 3
{{ client_ovpn_ca }}
{{ client_ovpn_cert }}
{{ client_ovpn_key }}
{{ client_ovpn_ta }}
This template dynamically replaces values such as port and protocol, which are defined in the vars/main.yml
.
Finally, let’s create a playbook to execute the OpenVPN role on your target servers.
🔹Create a playbook file |
vim openvpn-playbook.yml
🔹Add the following content |
---
- name: Deploy OpenVPN Server on CentOS 9
hosts: openvpn_servers
become: yes
roles:
- openvpn
This playbook applies the openvpn
role to all servers listed under the openvpn_servers
group.
Before running the playbook, it needs to reference an inventory file with the hostname and IP address of the target host or server.
🔹Create a inventory file |
vim openvpn_server_inventory
🔹Append the inventory file |
[openvpn_servers]
172.105.3.120 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
[all:vars]
ansible_python_interpreter=/usr/bin/python3
🔹Breakdown of the Inventory |
|
|
|
|
|
Your updated directory structure should look similar to:
.
├── openvpn-playbook.yml
├── openvpn_server_inventory
└── roles
└── openvpn
├── defaults
│ └── main.yml
├── files
├── handlers
│ └── main.yml
├── meta
│ └── main.yml
├── README.md
├── tasks
│ └── main.yml
├── templates
│ ├── base.conf.j2
│ └── server.conf.j2
├── tests
│ ├── inventory
│ └── test.yml
└── vars
└── main.yml
10 directories, 12 files
Now that everything is set up, you can run the playbook:
ansible-playbook -i openvpn_server_inventory openvpn-playbook.yml -K
For debugging and troubleshooting, you can run the playbook with the -v
option to enable verbose output. The level of detail increases with each additional v
(e.g., -vv
or -vvvv
).
ansible-playbook -i openvpn_server_inventory openvpn-playbook.yml -K -v
Ansible will automatically execute the tasks defined in your role, setting up OpenVPN on the target server.
Photo by admingeek from Infotechys
In addition to setting up the OpenVPN server, the playbook also generates a client .ovpn
configuration file and saves it under the ~/client-configs/
directory on the server. You can securely copy this file to your local machine using the following command:
scp root@:~/client-configs/.ovpn .
Replace <vpn_server_ip>
with your server’s IP address and <client_username>
with the actual VPN client name.
By using Ansible roles to automate the OpenVPN installation process, you create a more structured, reusable, and scalable deployment pipeline. You can easily modify the role for different configurations, add extra features, or deploy OpenVPN to multiple servers with minimal effort.
Did you find this article helpful? Your feedback is invaluable to us! Feel free to share this post with those who may benefit, and let us know your thoughts in the comments section below.
Learn about securing SSH connections on RHEL 9 and CentOS 9 with Ansible roles. This guide covers key SSH security practices, Ansible playbook setup, and
Learn how to install and configure OpenVPN server on CentOS Stream 9 from scratch. Step‑by‑step guide includes PKI setup, firewall configuration, client .ovpn setup, performance tuning, and
Learn about hardening OpenVPN security on RHEL 9 and CentOS 9 with best practices for authentication, encryption, firewall rules, logging, and intrusion detection. Table of Contents Introduction