DEV Community

Mo Rizal
Mo Rizal

Posted on

Zero-Touch Provisioning With Ansible: From 0 To Secure Production Ready Server

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:

provisioning flow

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.

Project Structure

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Creates the administrative user

  2. Grants the user sudo privileges

  3. Configures SSH key-based authentication

  4. 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 }}"
Enter fullscreen mode Exit fullscreen mode

Creating the Administrative User

- name: create admin user
      ansible.builtin.user:
        name: "{{ admin_user }}"
        shell: /bin/bash
        create_home: true
        groups: sudo
        append: true
Enter fullscreen mode Exit fullscreen mode

The username comes from the variable defined in group_vars/servers.yml:

admin_user: devops
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Updating the APT package cache

  2. Upgrading installed packages

  3. Configuring the server timezone

  4. 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
Enter fullscreen mode Exit fullscreen mode

Updating the APT Package Cache

- name: update apt package cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
Enter fullscreen mode Exit fullscreen mode

The update_cache option performs the equivalent of:

apt update
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The directory is owned by the administrative user defined in our variables:

admin_user: devops
Enter fullscreen mode Exit fullscreen mode

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:

  1. Hardening SSH access

  2. Configuring UFW as the host firewall

  3. Allowing only the required network traffic

  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The resulting configuration is:

PermitRootLogin no
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There are two important states being configured here:

enabled: true
Enter fullscreen mode Exit fullscreen mode

ensures that Fail2ban starts automatically when the server boots.

state: started
Enter fullscreen mode Exit fullscreen mode

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:

  1. Installing the required repository prerequisites

  2. Configuring Docker's official APT repository

  3. Installing Docker Engine and related components

  4. Enabling and starting the Docker service

  5. 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
Enter fullscreen mode Exit fullscreen mode

Installing Repository Prerequisites

- name: install docker repository prerequisites
      ansible.builtin.apt:
        name:
          - ca-certificates
          - curl
        state: present
        update_cache: true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

using:

- name: create docker keyring directory
      ansible.builtin.file:
        path: /etc/apt/keyrings
        state: directory
        mode: '0755'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

One useful detail here is:

suites:
  - "{{ ansible_facts['distribution_release'] }}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 engine

  • docker-ce-cli — docker command line interface

  • containerd.io — container runtime

  • docker-buildx-plugin — docker image build functionality

  • docker-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
Enter fullscreen mode Exit fullscreen mode

There are two desired states here.

enabled: true
Enter fullscreen mode Exit fullscreen mode

ensures docker starts automatically when the server boots.

state: started
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

instead of:

sudo docker ps
Enter fullscreen mode Exit fullscreen mode

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:

  1. Creating a dedicated system user for Node Exporter

  2. Downloading and installing Node Exporter

  3. Creating a systemd service

  4. Ensuring Node Exporter starts automatically

  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

The important part here is:

remote_src: true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The monitoring server address is defined in:

monitoring_server_ip: 192.168.122.1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)