Infrastructure as code with Ansible: roles for Ruby, swap, and sshd
Ansible configures servers over plain SSH with no agent to install. A practical guide to provisioning a Ruby app server with idempotent playbooks and reusable roles — and why agentless won us over.
Configuration management used to mean Chef or Puppet, and both ask the same thing up front: install an agent on every node, run a central server, and learn a domain-specific language before you can lay down a single file. For a small team with a handful of servers that is a lot of ceremony before any value. Ansible takes a different bet — it configures machines over plain SSH, with nothing to install on the target beyond Python (which is already there) — and on our infrastructure that bet has paid off completely.
Here is how we use it to provision a Ruby application server, and the ideas that make Ansible click.
Agentless is the whole pitch
The thing that sold us is what Ansible does not require. There is no agent daemon running on every box, no central master to keep alive and secure, no certificate dance to enrol a new node. The control machine — your laptop, or a CI runner — connects over SSH, pushes the configuration, runs it with Python, and disconnects. A freshly booted server with an SSH key and Python is immediately manageable; there is no bootstrap step where you install the tool before you can use the tool.
That property has knock-on benefits. The “infrastructure” is just files in a git repo — playbooks and roles in YAML — so it reviews, diffs, and rolls back like any other code. There is no server-side state to drift. And the barrier to starting is almost zero, which matters more than it sounds: the config-management tool you actually adopt beats the more powerful one you keep meaning to set up.
The inventory and the first playbook
You list your hosts in an inventory file, grouped by role:
# inventory
[web]
web-1 ansible_host=10.0.1.10
web-2 ansible_host=10.0.1.11
[db]
db-1 ansible_host=10.0.2.10
A playbook maps groups of hosts to the things that should be true of them:
# site.yml
- hosts: web
become: true # run with sudo
roles:
- common
- swap
- ruby
- nginx
- deploy
Run ansible-playbook -i inventory site.yml and Ansible connects to every host in
web, in parallel, and applies the listed roles in order. The same command run a
second time should change nothing — which brings us to the property that matters
most.
Idempotence: describe the end state, not the steps
The mental shift Ansible asks for is the same one every good infrastructure tool
asks for: stop writing scripts that do things and start declaring the state you
want. A shell script that runs adduser deploy fails the second time because the
user already exists. The Ansible equivalent declares the desired result, and the
module figures out whether any action is needed:
- name: ensure the deploy user exists
user:
name: deploy
groups: sudo
shell: /bin/bash
- name: ensure nginx is installed
apt:
name: nginx
state: present
Run that on a fresh box and it creates the user and installs nginx; run it again
and both tasks report ok (not changed) and do nothing. This idempotence is what
makes a playbook safe to run repeatedly — to converge a new server, to apply a
change to existing ones, or just to prove that reality still matches your code.
Each task reports ok, changed, or failed, so a run is also an audit: the
changed count tells you exactly what drifted since last time.
Roles: the reusable unit
A role is a self-contained bundle of tasks, templates, files, and variables for one
concern — ruby, nginx, swap — laid out in a conventional directory structure.
Roles are how a pile of tasks becomes a library you reuse across projects. A few we
keep around:
A swap role. Cloud instances often ship with no swap, and a Ruby app that spikes memory will be OOM-killed without it. The role makes a swapfile, idempotently:
- name: create swapfile
command: fallocate -l {{ swap_size }} /swapfile
args:
creates: /swapfile # the magic: skip if /swapfile already exists
- name: format swapfile
command: mkswap /swapfile
args:
creates: /swapfile.formatted
- name: enable swap
command: swapon /swapfile
when: ansible_swaptotal_mb < 1
The creates: argument is the idiom that makes a raw command idempotent — it
tells Ansible to skip the task if the file already exists, so a non-idempotent
shell command becomes safe to re-run.
A ruby role. Install a known Ruby (via rbenv or ruby-install), pin the
version with a variable, and lay down the build dependencies the native gems need:
- name: install build dependencies
apt:
name: "{{ item }}"
state: present
loop: [build-essential, libpq-dev, libssl-dev, zlib1g-dev]
- name: install ruby {{ ruby_version }}
command: "rbenv install -s {{ ruby_version }}"
become_user: deploy
The version lives in a variable, so upgrading Ruby across the fleet is a one-line change to a vars file plus a playbook run.
An sshd role. Server hardening you want identical everywhere: disable root login, turn off password authentication, and reload the daemon only when the config actually changes — using a handler:
- name: harden sshd
template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
notify: restart sshd # fires the handler, but only on change
# handlers/main.yml
- name: restart sshd
service:
name: ssh
state: restarted
Handlers are Ansible’s answer to “do this expensive thing only if something
changed”. The template task renders sshd_config from a Jinja2 template; if (and
only if) the rendered file differs from what is on disk, the notify triggers the
restart sshd handler at the end of the run. You never needlessly bounce the SSH
daemon, and a no-op run stays a true no-op.
Variables and templates keep it DRY
Jinja2 templates plus variables are what stop you copy-pasting near-identical config across environments. The nginx config, the database connection, the worker count — all rendered from variables that differ per group or per host. Your staging and production servers run the same roles, parameterised by different vars, which is how you guarantee they are actually alike instead of hoping they are.
Where it fits
Ansible is not the answer to everything. It provisions and configures running machines beautifully; it is not an orchestrator for launching them (that is the cloud API’s job, or Terraform’s) and it is not built for the constantly-churning container world arriving now. But for the very common case — turning a fresh Ubuntu box into a correctly configured, hardened, reproducible Ruby app server, and keeping a small fleet of them in sync — it is the most pleasant tool we have used. The agentless model means you can adopt it this afternoon, the idempotent roles mean your servers stop being snowflakes, and the whole thing lives in git where it belongs. For a small team, that combination is hard to beat.