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:
- There's an error partway through its execution and we need to re-run provisioning.
- You make some changes to the script and want to apply just those changes to your server (even years later).
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.
For this article I'm assuming Debian. Nix is another great way to reliably create VMs.↩