Automating OpenVPN Server Install on CentOS 9 Using Ansible Roles

Automating OpenVPN Installation with Ansible Roles on CentOS 9

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.

Table of Contents

🔈Introduction

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.


✅ What Are Ansible Roles?

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:

  • Maintain structure: Keep your playbooks organized and easier to manage.
  • Reusability: Reuse the same roles across multiple playbooks or projects.
  • Scalability: Easily scale your VPN setup by adding or modifying roles without touching the core configuration.

✅ Why Automate OpenVPN with Ansible Roles?

Automating the OpenVPN installation with Ansible roles offers several advantages:

  • Consistency: Every server gets configured in the same way, reducing human errors.
  • Modularity: Roles allow you to break down the setup into manageable chunks, like installation, configuration, and firewall rules.
  • Scalability: You can easily extend your automation to multiple servers, making it ideal for larger environments.
  • Maintenance: Updating configurations becomes simpler—modify the role, and it propagates across all your managed nodes.

🔧 Prerequisites

To follow along with this guide, you’ll need:

  • CentOS 9 server (or multiple servers) for installing OpenVPN.
  • Ansible installed on a control node (local machine).
  • SSH access with sudo privileges to the target servers.

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
				
			

🔄 Step 1: Setting Up Ansible Roles Directory

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:

  • defaults: Default variables for the role.
  • files: Files to copy to remote servers.
  • handlers: Tasks that respond to changes made by other tasks (like restarting a service).
  • meta: Metadata about the role.
  • tasks: The main tasks for the role.
  • templates: Jinja2 templates for dynamic file generation.
  • tests: Unit tests for the role (optional).
  • vars: Variables for the role.

For this automation, we’ll focus mainly on the tasks, templates, and vars directories.


🔄 Step 2: Define Variables for OpenVPN Installation

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 main.yml file 

				
					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:

  • openvpn_version: The OpenVPN version to install.
  • easy_rsa_version: The version of the Easy-RSA tool to use for generating certificates.
  • openvpn_port: The port OpenVPN will listen on.
  • openvpn_proto: The protocol to use (UDP by default).

🔹Navigate to the defaults directory

				
					cd roles/openvpn/defaults
				
			

🔹Append the main.yml file 

				
					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
				
			

🔄 Step 3: Create Tasks to Install OpenVPN

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 main.yml file with the following tasks

				
					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:

  • Install the necessary software packages.
  • Enable and configure the firewall for OpenVPN.
  • Generate the required keys and certificates.
  • Copy the OpenVPN configuration file (which we will define as a template).
  • Start the OpenVPN service.

🔄 Step 4: Create the OpenVPN Configuration Template

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 server.conf.j2

				
					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 base.conf.j2

				
					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

<ca>
{{ client_ovpn_ca }}
</ca>

<cert>
{{ client_ovpn_cert }}
</cert>

<key>
{{ client_ovpn_key }}
</key>

<tls-auth>
{{ client_ovpn_ta }}
</tls-auth>
				
			

This template dynamically replaces values such as port and protocol, which are defined in the vars/main.yml.


🔄 Step 5: Create the Playbook to Execute the Role

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.


🔄 Step 6: Create the Inventory file

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

  • [openvpn_servers]: This is a group label. You can later refer to this group in your playbooks or commands. If you have multiple servers, you can list them here under the same group.
  • 172.105.3.120: This is the IP address of your server. You will target this server using the Ansible playbook.
  • ansible_user=your_user: Replace your_user with the username that Ansible should use to log in. For example, it could be root, ubuntu, centos, or any other system user that has SSH access.
  • ansible_ssh_private_key_file=/path/to/your/private/key: The private key file path you’ll use to authenticate with the server. Replace /path/to/your/private/key with the correct path to your private key file.
  • [all:vars]: This section is used for global variables that apply to all hosts. Here, we’re setting the ansible_python_interpreter to /usr/bin/python3, which is the default for many modern Linux distributions.

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

				
			

🔄 Step 7: Run the Playbook

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.

Automate OpenVPN Installation with Ansible Roles on CentOS 9: Post Image

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@<vpn_server_ip>:~/client-configs/<client_username>.ovpn .
				
			

Replace <vpn_server_ip> with your server’s IP address and <client_username> with the actual VPN client name.


🏁 Conclusion

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.


👉 Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *