Skip to content
← All posts
6 min read Dawid Skłodowski

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.