ParaVocê Dev Blog

How do you provision a Linux VM?

There's many ways to provision a Linux VM. I'm going to show you my approach, which as usual, is aimed at keeping it as simple as possible.1

I use Ansible to provision servers. It isn't perfect (it's a huge tool, it's slow, and it uses YAML), but it's the most reliable approach I've found for this task.

The nice advantage to Ansible over running a shell script is all the steps ("tasks" in Ansible parlance) are designed with idempotence in mind. That is you can run the tasks again and again, and it won't change the outcome. This helps you reliably set up consistent servers.

This is really useful when:

First we'll need Ansible installed on our server. Enter our trusty friend the shell script, which we'll execute on the remote server:

#!/usr/bin/env bash
set -ex

# Update and upgrade the system
sudo apt update && sudo apt upgrade -y

# Install required packages
sudo apt install -y pipx

# Install Ansible
pipx install ansible

# Add Ansible to PATH if not already there
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
    echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
fi

echo "Initial setup complete. Please update the inventory.ini file with your server's IP address."
echo "Then run the Ansible playbook with: ansible-playbook -i inventory.ini playbook.yaml"

And because I'm forgetful, I have a little addition to my Makefile, so I can remember how to run it:

provision:
	rsync -chazP ansible/provision.sh root@server1:
	ssh root@server1 './provision.sh'
.PHONY: provision

Ansible uses "playbooks," which you can think of as a series of tasks defined in YAML. Let's take a look at how I provision the server by installing updates and configuring SSH:

---
- name: Provision Debian 12
  hosts: all
  become: yes

  vars:
    new_user: "{{ lookup('file', 'user.txt') }}"

  tasks:
    - name: Create non-root user
      user:
        name: "{{ new_user }}"
        groups: sudo
        shell: /bin/bash

    - name: Set authorized keys taken from url
      authorized_key:
        user: "{{ new_user }}"
        state: present
        key: "https://meta.sr.ht/~{{ new_user }}.keys"

    - name: Allow sudo without password for new user
      lineinfile:
        dest: /etc/sudoers
        state: present
        regexp: "^{{ new_user }} ALL="
        line: "{{ new_user }} ALL=(ALL) NOPASSWD: ALL"
        validate: "visudo -cf %s"

    - name: Ensure public key authentication is enabled
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#?PubkeyAuthentication"
        line: "PubkeyAuthentication yes"
      notify: Restart SSH

    - name: Disable password-based SSH authentication
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#?PasswordAuthentication"
        line: "PasswordAuthentication no"
      notify: Restart SSH

  handlers:
    - name: Restart SSH
      service:
        name: ssh
        state: restarted

You see lookup('file', 'user.txt') above? That's having Ansible look at my own PC (not the server) for a file named files/user.txt and using that content throughout the rest of the script.

Also notice how it's pulling the authorized keys directly from Sourcehut! You could just as easily do the same from Github with https://github.com/{{ new_user }}.keys, or include them as a file lookup like we did for the user.

I could do all of this in a shell script, but making it idempotent is error prone. I am far too careless of a developer to write perfect shell scripts that account for edge-cases. Ansible gives me a better default behavior for provisioning.

As another example playbook, here's how I run an app with its own daemon user and systemd service file:

---
- name: Provision app
  hosts: all
  become: yes
  vars:
    user: "_app"
    group: "_app"

  tasks:
    - name: Create app group
      group:
        name: "{{ group }}"
        system: yes
        state: present

    - name: Create app user
      user:
        name: "{{ user }}"
        system: yes
        group: "{{ group }}"
        create_home: yes
        home: "/home/{{ user }}"
        shell: /usr/sbin/nologin

    - name: Create systemd service file
      copy:
        content: |
          [Unit]
          Description=app Server
          After=network.target

          [Service]
          Type=simple
          User=_app
          ExecStart=/home/_app/app
          WorkingDirectory=/home/_app
          Restart=always
          RestartSec=5
          Environment=ENV=production
          Environment=ENV_FILE=production.ini

          [Install]
          WantedBy=multi-user.target
        dest: /etc/systemd/system/app.service
        mode: "0644"
        owner: root
        group: root

    - name: Reload systemd
      systemd:
        daemon_reload: yes

    - name: Enable and start app service
      systemd:
        name: app
        enabled: yes
        state: started

In order for Ansible to talk to our server, we need an inventory. A simple inventory.ini file might look like this (you'd replace the IP with your own):

[debian]
100.0.0.1 ansible_user=root

Notice that you can group them under headings. In the playbook above, I used hosts: all, but you could use hosts: debian to target this particular IP. The group names are arbitrary, so you can name them whatever makes sense in your project.

To run this we'd call:

ansible-playbook ansible/provision_app.yaml -i ansible/inventory.ini

We could stop here, but it's often useful to have several playbooks, each performing one task. For instance, I have the following in a simple project:

$ ls ansible/playbooks
provision.yaml # download updates, configure ssh
postgres.yaml  # set up postgres
caddy.yaml     # set up a reverse proxy
app.yaml       # set up a daemon user and enable the service

If we want to run all of these scripts at once instead of individually, we can create playbook.yaml and instruct it to import the others:

- import_playbook: provision.yaml
- import_playbook: postgres.yaml
- import_playbook: caddy.yaml
- import_playbook: app.yaml

And run it just as before:

ansible-playbook ansible/playbook.yaml -i ansible/inventory.ini

That's a quick intro to Ansible. Like Git, Ansible is a huge tool, but I found the "2% of its capabilities" workflow that works for me, and that 2% might be enough for you, too.

In my next post, I'll be describing my journey from horizontal to vertical scaling and why I made the leap. Subscribe on RSS or email to follow along.

  1. For this article I'm assuming Debian. Nix is another great way to reliably create VMs.

#devops #sysadmin