Provisioning a new server is often more complicated than simply installing a few packages.
A fresh ubuntu server may start with almost nothing configured. An engineer still needs to create users, configure SSH access, set the hostname configure the firewall, and make sure the required services are running correctly.
When these steps are performed manually, the process can become time-consuming and inconsistent.
One server might have slightly different configurations from another. A firewall rule might be forgotten, SSH password authentication might remain enabled, or a monitoring agent might not be installed at all.
The problem is not only how to configure a server, but how to configure every server consistently.
This is where infrastructure automation become valuable.
In this article, we will build a zero-touch server provisioning workflow using ansible, starting from a fresh Ubuntu server and automatically transforming it into a standardized and secured server environment.
The provisioning process covers:
Bootstrapping and configuring the server
Applying system security hardening
Installing and configuring docker
Setting up system monitoring
Restricting network access
The goal is simple:
Start with a fresh ubuntu server, run the ansible playbook, and let ansible handle the rest
By the end of the process, the server will be ready to run applications, protected with a basic security baseline, equipped with Docker, and connected to the monitoring infrastructure.
Provisioning Flow
Before looking at the ansible configuration, it is important to understand what we are actually building.
The goal of this project is not to create a collection of independent Ansible playbooks. The goal is to create a repeatable provisioning workflow that can take a fresh Ubuntu server through several configuration stages until it reaches standardized state.
The provisioning flow looks like this:
Project Structure
Instead of putting the entire provisioning process into a single playbook, the project separates configuration into several files, each with a specific responsibility.
The easiest way to understand the architecture is to follow how ansible processes a command.
When we run:
ansible-playbook site.yml
Ansible first loads its configuration from ansible.cfg
[defaults]
inventory = inventory/hosts.ini
interpreter_python = auto_silent
This configuration tells Ansible where to find the inventory
Then inventory/hosts.ini tells ansible which servers it should manage and how to connect to them.
[servers]
vm1 ansible_host=192.168.122.68
[servers:vars]
ansible_user=root
# ansible_user=devops
ansible_ssh_private_key_file=~/.ssh/ansible
The servers group is important because the playbooks target this group:
hosts: servers
Ansible can therefore apply the same configuration to every server belonging to that group.
group_vars/servers.yml
This file contains variables that apply to the entire servers group:
server_timezone: Asia/Makassar
admin_user: devops
server_hostname: ansible-demo
server_ssh_public_key: YOUR_SSH_PUBLIC_KEY
monitoring_server_ip: 192.168.122.1
node_exporter_version: 1.9.1
node_exporter_arch: linux-amd64
This allows the playbooks to focus on what should be configured, while server-specific values remain outside the playbooks.
For example, instead of writing:
name: devops
inside a playbook, we use:
name: "{{ admin_user }}"
The Main Entry Point
The entire provisioning process is orchestrated through site.yml:
- name: bootstrap server
import_playbook: playbooks/bootstrap.yml
- name: configure base system
import_playbook: playbooks/base.yml
- name: harden server
import_playbook: playbooks/security.yml
- name: install docker
import_playbook: playbooks/docker.yml
- name: configure monitoring
import_playbook: playbooks/monitoring.yml
This file does not contain the actual server configuration tasks. Instead, it defines the order in which the provisioning stages are executed.
1. Bootstrap Playbook
At the start, the server does not have the administrative user that we want to use for day to day management. The initial connection therefore uses the existing root account.
The purpose of the bootstrap stage is to establish the foundation required for the rest of the provisioning process.
The bootstrap playbook performs four main tasks:
Creates the administrative user
Grants the user sudo privileges
Configures SSH key-based authentication
Sets the server hostname
The important part of this stage is that root access is only required for the initial bootstrap.
Once the devops user has been created and configured, subsequent playbooks connect to the server using that dedicated administrative account.
The playbook looks like this:
- name: bootstrap fresh ubuntu server
hosts: servers
tasks:
- name: create admin user
ansible.builtin.user:
name: "{{ admin_user }}"
shell: /bin/bash
create_home: true
groups: sudo
append: true
- name: configure passwordless sudo
ansible.builtin.copy:
content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n"
dest: "/etc/sudoers.d/{{ admin_user }}"
owner: root
group: root
mode: '0440'
- name: configure ssh access for admin user
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ server_ssh_public_key }}"
- name: set server hostname
ansible.builtin.hostname:
name: "{{ server_hostname }}"
Creating the Administrative User
- name: create admin user
ansible.builtin.user:
name: "{{ admin_user }}"
shell: /bin/bash
create_home: true
groups: sudo
append: true
The username comes from the variable defined in group_vars/servers.yml:
admin_user: devops
This means the playbook does not depend on a specific username.
Ansible creates the user's home directory and adds the user to the sudo group:
groups: sudo
append: true
The append: true option is important because it adds the user to the specified group without removing the user from other existing groups.
At the end of this task, the server has a dedicated administrative identity that can be used for subsequent configuration.
Configuring Passwordless Sudo
- name: configure passwordless sudo
ansible.builtin.copy:
content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n"
dest: "/etc/sudoers.d/{{ admin_user }}"
owner: root
group: root
mode: '0440'
Instead of modifying the main /etc/sudoers file, the configuration is placed inside:
/etc/sudoers.d/
This keeps the custom sudo configuration separated from the system's main sudo configuration.
The resulting file is:
/etc/sudoers.d/devops
with permissions:
0440
The user can now execute administrative commands through sudo without being prompted for a password.
This is useful for automated provisioning because subsequent ansible tasks need to perform privileged operations.
Configuring SSH Key Authentication
- name: configure ssh access for admin user
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ server_ssh_public_key }}"
The public key is provided through:
server_ssh_public_key: YOUR_SSH_PUBLIC_KEY
Ansible manages the user's authorized_keys file instead of requiring us to manually create it.
Setting the Hostname
- name: set server hostname
ansible.builtin.hostname:
name: "{{ server_hostname }}"
A predictable hostname is useful for identifying the server through monitoring systems, logs, and other infrastructure tooling.
2. Base Playbook
After the bootstrap stage, the server now has a dedicated administrative user and the basic access configuration required for ansible to continue provisioning it.
The next step is to establish a consistent baseline for the operating system.
A fresh Ubuntu installation may contain different package versions, system settings, or filesystem directories depending on how the server was created. Before installing infrastructure components, it is useful to bring the system into a predictable state.
The base.yml playbook is responsible for:
Updating the APT package cache
Upgrading installed packages
Configuring the server timezone
Creating required application directories
The playbook looks like this:
- name: configure base ubuntu server
hosts: servers
become: true
tasks:
- name: update apt package cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: upgrade installed packages
ansible.builtin.apt:
upgrade: dist
- name: configure server timezone
community.general.timezone:
name: "{{ server_timezone }}"
- name: create standard application directories
ansible.builtin.file:
path: "/home/{{ admin_user }}/{{ item }}"
state: directory
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: '0755'
loop:
- apps
- monitoring
Updating the APT Package Cache
- name: update apt package cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
The update_cache option performs the equivalent of:
apt update
The cache_valid_time prevents ansible from unnecessarily updating the package cache on every execution when the cache is still considered valid.
This is useful when the playbook contains multiple package related tasks because it reduces unnecessary repository requests.
Upgrading the System
- name: upgrade installed packages
ansible.builtin.apt:
upgrade: dist
This is equivalent to performing a distribution-level package upgrade through APT.
The purpose is to ensure that the server starts the rest of the provisioning process with its existing packages brought up to date.
This also establishes a more predictable baseline before we install additional components such as docker and monitoring agents.
Configuring the Timezone
- name: configure server timezone
community.general.timezone:
name: "{{ server_timezone }}"
Consistent timezone configuration is particularly useful for infrastructure because timestamps appear throughout:
System logs
Application logs
Monitoring data
Scheduled jobs
Troubleshooting sessions
A consistent timezone makes these timestamps easier to correlate when investigating an issue.
Creating the Application Directory
- name: create standard application directories
ansible.builtin.file:
path: "/home/{{ admin_user }}/{{ item }}"
state: directory
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: '0755'
loop:
- apps
- monitoring
Rather than allowing application files to be placed arbitrarily throughout the filesystem, the provisioning process establishes a predictable location:
/home/devops/apps
/home/devops/monitoring
The directory is owned by the administrative user defined in our variables:
admin_user: devops
This gives us a standardized location that can later be used when deploying applications or additional infrastructure components.
3. Security Playbook
The server now has a predictable operating system baseline.
However, a freshly provisioned server should not be considered secure simply because its packages are up to date. We still need to reduce unnecessary access, protect the SSH service, control network traffic, and add a mechanism to respond to repeated authentication attempts.
This is the responsibility of the security.yml playbook.
The security playbook focuses on four areas:
Hardening SSH access
Configuring UFW as the host firewall
Allowing only the required network traffic
Installing and enabling Fail2ban
The playbook looks like this:
- name: harden ubuntu server
hosts: servers
become: true
tasks:
- name: disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -t -f %s'
notify: restart ssh
- name: disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: '/usr/sbin/sshd -t -f %s'
notify: restart ssh
- name: allow SSH through firewall
community.general.ufw:
rule: allow
port: '22'
proto: tcp
- name: enable UFW
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: install Fail2ban
ansible.builtin.apt:
name: fail2ban
state: present
- name: enable Fail2ban
ansible.builtin.systemd_service:
name: fail2ban
enabled: true
state: started
handlers:
- name: restart ssh
ansible.builtin.systemd_service:
name: ssh
state: restarted
Hardening SSH
SSH is one of the primary entry points into a server, so securing it is an important part of the baseline.
The first task disables direct root login:
- name: disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -t -f %s'
notify: restart ssh
The resulting configuration is:
PermitRootLogin no
This means an external SSH connection can no longer authenticate directly as root.
This is particularly important because the bootstrap stage has already created the devops administrative account.
The intended access model is therefore:
rather than:
Disable SSH Password Authentication
- name: disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: '/usr/sbin/sshd -t -f %s'
notify: restart ssh
After this configuration, SSH authentication relies on ssh key configured during the bootstrap stage.
This removes password based SSH authentication from the server's remote access path.
Validating SSH Configuration
Notice that both SSH configuration tasks include:
validate: '/usr/sbin/sshd -t'
This is an important detail.
Ansible does not immediately apply an invalid SSH configuration. The sshd -t command validates the configuration before the file is accepted.
This reduces the risk of provisioning an invalid SSH configuration that could prevent future connections.
The tasks also use a handler:
notify: restart ssh
The SSH service is therefore restarted only when the configuration actually changes.
This is one of the advantages of using Ansible modules instead of simply executing shell commands.
Installing Security Packages
The playbook installs UFW and Fail2ban:
UFW provides a simple interface for managing the linux host firewall, while Fail2ban can monitor authentication related logs and temporarily block clients that repeatedly fail authentication.
These tools address different parts of the security baseline:
Configuring UFW
The first firewall rule allows SSH:
- name: allow ssh
community.general.ufw:
rule: allow
name: OpenSSH
This rule is intentionally configured before enabling UFW.
The firewall is then enabled with a default-deny policy for incoming traffic:
- name: enable ufw
community.general.ufw:
state: enabled
policy: deny
direction: incoming
The resulting security model is essentially:
This follows the principle of default deny: services should not become reachable simply because they happen to be listening on a network port.
Additional ports can then be explicitly allowed when they are required by later components of the infrastructure.
For example, the monitoring stage will later add a specific rule for Node Exporter rather than exposing all ports on the server.
Enabling Fail2ban
The final security task ensures that Fail2ban is enabled and running:
- name: enable fail2ban
ansible.builtin.systemd_service:
name: fail2ban
enabled: true
state: started
There are two important states being configured here:
enabled: true
ensures that Fail2ban starts automatically when the server boots.
state: started
ensures that the service is running immediately after provisioning.
This means the server does not need to be manually configured after deployment to activate the security service.
4. Docker Playbook
With the operating system configured and the initial security baseline in place, the next step is to prepare the server to run containerized workloads.
For this project, docker is installed through the official docker APT repository rather than relying on the docker package provided by the default ubuntu repositories.
The docker.yml playbook is responsible for:
Installing the required repository prerequisites
Configuring Docker's official APT repository
Installing Docker Engine and related components
Enabling and starting the Docker service
Allowing the administrative user to manage Docker
The playbook looks like this:
- name: install docker on ubuntu server
hosts: servers
become: true
tasks:
- name: install docker repository prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
state: present
update_cache: true
- name: create docker keyring directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: download docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: '0644'
- name: add docker APT repository
ansible.builtin.deb822_repository:
name: docker
types: deb
uris:
- https://download.docker.com/linux/ubuntu
suites:
- "{{ ansible_facts['distribution_release'] }}"
components:
- stable
architectures:
- amd64
signed_by: /etc/apt/keyrings/docker.asc
state: present
- name: install docker engine and compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
- name: ensure docker service is enabled and running
ansible.builtin.systemd_service:
name: docker
enabled: true
state: started
- name: add admin user to docker group
ansible.builtin.user:
name: "{{ admin_user }}"
groups: docker
append: true
Installing Repository Prerequisites
- name: install docker repository prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
state: present
update_cache: true
ca-certificates allows the system to properly validate HTTPS certificates, while curl is used to retrieve the Docker repository signing key.
These packages provide the basic requirements for securely adding the external repository.
Creating the APT Keyring Directory
/etc/apt/keyrings
using:
- name: create docker keyring directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
APT repository signing keys are stored separately from the repository configuration.
This provides a clear location for repository specific signing keys instead of placing them in a global trusted key configuration.
Adding Docker's Repository Signing Key
- name: download docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: '0644'
The signing key is then referenced by the docker repository configuration.
This allows APT to verify that packages retrieved from the docker repository are signed by the expected key.
The important part here is that the playbook does not simply add an arbitrary repository and trust it globally. The repository is explicitly associated with its signing key.
Configuring the Docker APT Repository
- name: add docker APT repository
ansible.builtin.deb822_repository:
name: docker
types: deb
uris:
- https://download.docker.com/linux/ubuntu
suites:
- "{{ ansible_facts['distribution_release'] }}"
components:
- stable
architectures:
- amd64
signed_by: /etc/apt/keyrings/docker.asc
state: present
One useful detail here is:
suites:
- "{{ ansible_facts['distribution_release'] }}"
Instead of hardcoding an ubuntu release name, ansible obtains the distribution release from the target system.
This makes the playbook less dependent on a specific ubuntu release.
The repository is also restricted to the stable component and amd64 architecture:
components:
- stable
architectures:
- amd64
Installing Docker
- name: install docker engine and compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
Rather than installing only the docker engine package, the playbook installs the components needed for a practical container environment.
The packages include:
docker-ce— docker enginedocker-ce-cli— docker command line interfacecontainerd.io— container runtimedocker-buildx-plugin— docker image build functionalitydocker-compose-plugin— docker compose
This gives the server the tooling required to build, run, and manage containerized applications.
Ensuring Docker Is Running
- name: ensure docker service is enabled and running
ansible.builtin.systemd_service:
name: docker
enabled: true
state: started
There are two desired states here.
enabled: true
ensures docker starts automatically when the server boots.
state: started
ensures docker is running immediately after the provisioning stage.
This means that once provisioning finishes, the server is already capable of running containers without requiring manual intervention.
Allowing the Administrative User to Manage Docker
The final task adds the administrative user to the docker group:
- name: add admin user to docker group
ansible.builtin.user:
name: "{{ admin_user }}"
groups: docker
append: true
This allows the administrative user to interact with the docker daemon without prefixing every docker command with sudo.
For example, the user can run:
docker ps
instead of:
sudo docker ps
5. Monitoring Playbook
The server is now configured, hardened, and ready to run containerized workloads.
There is one final piece missing: observability.
A server can be perfectly configured and still become difficult to operate if we cannot see its resource usage and system health.
For this project, Prometheus Node Exporter is used to expose hardware and operating system metrics that can be collected by Prometheus.
The monitoring.yml playbook is responsible for:
Creating a dedicated system user for Node Exporter
Downloading and installing Node Exporter
Creating a systemd service
Ensuring Node Exporter starts automatically
Restricting access to the metrics endpoint
The playbook looks like this:
- name: configure monitoring agent
hosts: servers
become: true
tasks:
- name: create node exporter user
ansible.builtin.user:
name: node_exporter
system: true
shell: /usr/sbin/nologin
create_home: false
- name: download node exporter
ansible.builtin.get_url:
url: "https://github.com/prometheus/node_exporter/releases/download/v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}.tar.gz"
dest: "/tmp/node_exporter-{{ node_exporter_version }}.tar.gz"
mode: '0644'
- name: extract node exporter
ansible.builtin.unarchive:
src: "/tmp/node_exporter-{{ node_exporter_version }}.tar.gz"
dest: /tmp
remote_src: true
creates: "/tmp/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}"
- name: install node exporter binary
ansible.builtin.copy:
src: "/tmp/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}/node_exporter"
dest: /usr/local/bin/node_exporter
owner: root
group: root
mode: '0755'
remote_src: true
- name: create node exporter systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/node_exporter.service
owner: root
group: root
mode: '0644'
content: |
[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter
[Install]
WantedBy=multi-user.target
notify: restart node exporter
- name: enable and start node exporter
ansible.builtin.systemd_service:
name: node_exporter
enabled: true
state: started
daemon_reload: true
- name: allow node exporter from monitoring server
community.general.ufw:
rule: allow
from_ip: "{{ monitoring_server_ip }}"
to_port: '9100'
proto: tcp
handlers:
- name: restart node exporter
ansible.builtin.systemd_service:
name: node_exporter
state: restarted
Creating a Dedicated System User
The first step is to create a dedicated system user:
- name: create node exporter user
ansible.builtin.user:
name: node_exporter
system: true
shell: /usr/sbin/nologin
create_home: false
Node Exporter does not need an interactive shell or a home directory.
Therefore, instead of running the service as root, we create a dedicated user with:
shell: /usr/sbin/nologin
This follows the principle of least privilege.
The service only needs to perform its intended function, so there is no reason for it to have an interactive login account.
Downloading Node Exporter
The Node Exporter version is controlled through group_vars/servers.yml:
node_exporter_version: 1.9.1
node_exporter_arch: linux-amd64
The playbook uses these variables to construct the download url:
- name: download node exporter
ansible.builtin.get_url:
url: "https://github.com/prometheus/node_exporter/releases/download/v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}.tar.gz"
dest: "/tmp/node_exporter-{{ node_exporter_version }}.tar.gz"
mode: '0644'
If we need to upgrade Node Exporter later, the version can be changed in the variables file instead of modifying the playbook itself.
For example:
node_exporter_version: 1.10.2
Extracting the Binary
The downloaded archive is extracted using Ansible's unarchive module:
- name: extract node exporter
ansible.builtin.unarchive:
src: "/tmp/node_exporter-{{ node_exporter_version }}.tar.gz"
dest: /tmp
remote_src: true
creates: "/tmp/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}"
The important part here is:
remote_src: true
The archive already exists on the target server, so ansible does not need to transfer it from the machine running Ansible.
The creates parameter also provides an idempotency check.
If the extracted directory already exists, Ansible does not need to extract the archive again.
Installing the Node Exporter Binary
The Node Exporter binary is then copied into:
/usr/local/bin/node_exporter
using:
- name: install node exporter binary
ansible.builtin.copy:
src: "/tmp/node_exporter-{{ node_exporter_version }}.{{ node_exporter_arch }}/node_exporter"
dest: /usr/local/bin/node_exporter
owner: root
group: root
mode: '0755'
remote_src: true
The binary is owned by root, while the service itself will run as the dedicated node_exporter user.
This creates a separation between the executable and the account that executes it.
Creating the systemd Service
Installing the binary alone does not make Node Exporter a managed system service.
The playbook therefore creates:
/etc/systemd/system/node_exporter.service
with:
[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter
[Install]
WantedBy=multi-user.target
Enabling and Starting Node Exporter
The service is then enabled and started:
- name: enable and start node exporter
ansible.builtin.systemd_service:
name: node_exporter
enabled: true
state: started
daemon_reload: true
The daemon_reload option tells systemd to reload its service definitions after Ansible creates the new unit file.
The two important states are:
enabled: true
state: started
The first ensures Node Exporter starts automatically after a reboot.
The second ensures it is already running when provisioning finishes.
At this point, Node Exporter exposes its metrics endpoint on the standard port:
9100
Restricting Monitoring Access
The metrics endpoint contains information about the server's operating system and resource usage, so there is no reason to expose it to every host that can reach the server.
The playbook therefore adds a specific UFW rule:
- name: allow node exporter from monitoring server
community.general.ufw:
rule: allow
from_ip: "{{ monitoring_server_ip }}"
to_port: '9100'
proto: tcp
The monitoring server address is defined in:
monitoring_server_ip: 192.168.122.1
This results in a much narrower network rule:
This is an important continuation of the security model introduced in the Security Playbook.
We do not simply open port 9100 to the entire network:
9100 → 0.0.0.0/0
Instead, access is explicitly limited to the monitoring server.
Conclusion
Provisioning a server manually may seem simple when there is only one server to configure. But as the number of servers grows, manual configuration quickly becomes difficult to maintain and can lead to configuration drift.
In this project, we have built an ansible workflow that:
Bootstrapping and configuring the server
Applying system security hardening
Installing and configuring Docker
Setting up system monitoring
Restricting network access
Instead of relying on a sequence of manual commands, the desired server state is now defined as code that can be reviewed, version controlled, reproduced, and applied consistently.
Define the desired state. Automate it. Repeat it
You can find the source code for this article in my github repository: https://github.com/muhammadyulasfipahrizal/zero-touch-ansible.git







Top comments (0)