Avatar (Fabio Alessandro Locati|Fale)'s blog
Nebula from Ansible

Nebula from Ansible

November 6, 2026

For many years, I have managed all my systems with Ansible. For a similar amount of time, I have used Nebula as my VPN of choice, so I can always connect securely to all my systems, even on the move.

There are no official Ansible modules for Nebula, so over the years many people asked me how I manage it. I changed my approach over time. Although my previous iterations worked, I wasn’t satisfied enough to share the details. I’m now at a point where I think the process is fairly good, so here I am.

My current setup leverages Ansible, Nebula, and SOPS (that I previously talked about here).

To function, each Nebula node needs a CA certificate, node certificate, key, and configuration file, plus the Nebula software installed and its daemon started. You’ll need to manage those items differently. Nebula supports bundling the CA certificate, node certificate, key, and configuration in the same configuration file. Still, I prefer to keep them separate because of how I do node provisioning. Since I use bootc for the operating system and only publish images within the Nebula network itself, I find it easier to use a generic configuration I deploy on the node to initialize the deployment.

To start, let’s make sure Nebula is present by adding to roles/nebula/tasks/main.yaml:

- name: Ensure nebula is installed
  ansible.builtin.package:
    name: nebula
    state: present

The node certificate is fairly simple to manage, since it is a plain text artifact that needs to land on the node. I define it in a host_vars file, and then I copy its content to the host. Since I use SOPS, I prefer to use inventory/host_vars/[host].sops.yaml, so it would contain:

nebula_crt: |
    -----BEGIN NEBULA CERTIFICATE V2-----
    ...
    -----END NEBULA CERTIFICATE V2-----

On the other hand, I manage Nebula using a nebula role, so the code to deploy it would be in roles/nebula/tasks/main.yaml and would look like:

- name: Ensure Nebula certificate is present
  ansible.builtin.copy:
    content: "{{ nebula_crt }}"
    dest: /etc/nebula/client.crt
    owner: root
    group: root
    mode: "0644"
  notify: Restart Nebula

This code will copy the content of the variable into the /etc/nebula/client.crt file and always ensure they match. As you can see, there is a notify: Restart Nebula, which is a trigger for a handler that is stored in roles/nebula/handlers/main.yaml and is:

- name: Restart Nebula
  ansible.builtin.systemd_service:
    name: nebula
    state: restarted

This ensures that every time the certificate is modified, the service restarts and applies the change.

Similarly, we can manage the CA certificate, with the difference that the configuration will be the same for all hosts, so it is better to place it in inventory/group_vars/all.yaml:

nebula_ca: |
    -----BEGIN NEBULA CERTIFICATE V2-----
    ...
    -----END NEBULA CERTIFICATE V2-----

To copy the CA certificate to the right place, it is enough to add roles/nebula/tasks/main.yaml:

- name: Ensure Nebula CA is present
  ansible.builtin.copy:
    content: "{{ nebula_ca }}"
    dest: /etc/nebula/ca.crt
    owner: root
    group: root
    mode: "0644"
  notify: Restart Nebula

Like the previous one, this will ensure that the content is kept consistent on every run and, if it changes, will use the same Restart Nebula handler we discussed.

For the private key, we can do something similar, but since it should stay secret, we will use SOPS to encrypt the content. In the SOPS configuration, in .sops.yaml we will have something like:

creation_rules:
  - path_regex: inventory/(host|group)_vars/.*\.yaml$
    encrypted_regex: '^(.*_password|.*_secret|.*_key|.*_token)$'
    pgp: YOUR_KEY_FINGERPRINT

As you can see from the encrypted_regex field, only the values for keys that end in _password, _secret, _key, or _token get encrypted. So we need to use this naming schema. We will therefore modify the host_vars file with sops inventory/host_vars/[host].sops.yaml adding the key:

nebula_key: |
    -----BEGIN NEBULA X25519 PRIVATE KEY-----
    ...
    -----END NEBULA X25519 PRIVATE KEY-----

By saving and exiting, SOPS will encrypt it, so that if you open it with a text editor, the field will look like:

nebula_key: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]

We can now add to roles/nebula/tasks/main.yaml the code to deploy the key as well:

- name: Ensure Nebula key is present
  ansible.builtin.copy:
    content: "{{ nebula_key }}"
    dest: /etc/nebula/client.key
    owner: root
    group: root
    mode: "0600"
  no_log: true
  notify: Restart Nebula

Like the previous ones, this will ensure that the content is kept consistent on every run and, if it changes, will use the same Restart Nebula handler we discussed.

The only thing we still need to do is add the configuration. For this, we are going to use a template in roles/nebula/templates/client.yaml.j2:

#jinja2: trim_blocks: True, lstrip_blocks: True
{% set config = {} %}
{% set _ = config.update({'pki': {
        'ca': '/etc/nebula/ca.crt',
        'cert': '/etc/nebula/client.crt',
        'key': '/etc/nebula/client.key'
    }}) %}
{% set _ = config.update({'static_host_map': nebula_static_hostmap}) %}
{% if nebula_am_lighthouse %}
{%   set _ = config.update({'lighthouse': {'am_lighthouse': true}}) %}
{%   set _ = config.update({'listen': {'host': '::', 'port': 4242}}) %}
{% else %}
{%   set _ = config.update({'lighthouse': {'hosts': nebula_lighthouses}}) %}
{%   set _ = config.update({'listen': {'host': '::', 'port': 0}}) %}
{% endif %}
{% if nebula_am_relay %}
{%   set _ = config.update({'relay': {'am_relay': true}}) %}
{% elif nebula_relays %}
{%   set _ = config.update({'relay': {'relays': nebula_relays}}) %}
{% endif %}
{% set _ = config.update({'firewall': {
       'outbound': nebula_firewall_outbound,
       'inbound': nebula_firewall_inbound_common + nebula_firewall_inbound_extra,
    }}) %}
---
{{ config | to_nice_yaml(indent=2, width=1000) }}

As you can see, this template has a number of variables, and you will need to fill them in before it can work. Some of them are global, or can be, so we can set them in inventory/group_vars/all.yaml:

nebula_lighthouses:
  - HOST_1
  - HOST_2
nebula_static_hostmap:
  HOST_1:
    - DNS:PORT
  HOST_2:
    - DNS:PORT
nebula_am_lighthouse: false
nebula_am_relay: false
nebula_relays: []
nebula_firewall_outbound:
  - port: any
    proto: any
    host: any
nebula_firewall_inbound_common:
  - port: any
    proto: icmp
    host: any
  - port: 22
    proto: tcp
    groups:
      - MYGROUP
nebula_firewall_inbound_extra: []

Setting all variables in inventory/group_vars/all.yaml provides sensible defaults, and we only need to override the variables we need on individual hosts explicitly. To do so, you set the variables with the same name in inventory/host_vars/[host].sops.yaml.

We can then add to roles/nebula/tasks/main.yaml the code to apply the template:

- name: Ensure Nebula configuration file is present
  ansible.builtin.template:
    src: client.yaml.j2
    dest: /etc/nebula/client.yml
    owner: root
    group: root
    mode: "0640"
  notify: Restart Nebula

Now that all the configuration is present, we can make sure that Nebula is started and enabled by adding to roles/nebula/tasks/main.yaml:

- name: Ensure that nebula is started and starts at every boot
  ansible.builtin.service:
    name: nebula
    state: started
    enabled: true

By implementing the code above, you will have Nebula configured and managed by Ansible. You could further refine or simplify the code, for instance, by bundling the CA certificate, node certificate, and key into the configuration to avoid managing multiple files.