Ansible + Serverspec — Test Infrastructure Code

Introduction

Serverspec is a Ruby-based testing framework that verifies your server configuration matches expectations. Combined with Ansible, it creates a test-driven infrastructure workflow: Ansible provisions servers, Serverspec validates the results. This catches configuration drift, broken playbooks, and missing dependencies before they hit production.

Install Serverspec

# Install Ruby and Serverspec
sudo apt install ruby ruby-dev -y
gem install serverspec

# Initialize Serverspec
serverspec-init
# Select: SSH backend
# Enter hostname when prompted

Directory Structure

project/
├── ansible/
│   ├── site.yml
│   ├── roles/
│   │   └── webserver/
│   │       └── tasks/main.yml
│   └── inventory/
├── spec/
│   ├── spec_helper.rb
│   └── webserver/
│       └── nginx_spec.rb
└── Rakefile

Write Serverspec Tests

# spec/webserver/nginx_spec.rb
require 'spec_helper'

describe 'Nginx web server' do
  describe package('nginx') do
    it { should be_installed }
  end

  describe service('nginx') do
    it { should be_enabled }
    it { should be_running }
  end

  describe port(80) do
    it { should be_listening }
  end

  describe port(443) do
    it { should be_listening }
  end

  describe file('/etc/nginx/nginx.conf') do
    it { should exist }
    it { should be_file }
    it { should be_owned_by 'root' }
    it { should be_mode 644 }
    its(:content) { should match /worker_processes\s+auto/ }
  end

  describe file('/etc/nginx/sites-enabled/default') do
    it { should be_symlink }
  end

  describe command('curl -s -o /dev/null -w "%{http_code}" localhost/index.html') do
    its(:stdout) { should match /200/ }
  end
end

Test Database Servers

# spec/database/postgresql_spec.rb
require 'spec_helper'

describe 'PostgreSQL database' do
  describe package('postgresql-16') do
    it { should be_installed }
  end

  describe service('postgresql') do
    it { should be_enabled }
    it { should be_running }
  end

  describe port(5432) do
    it { should be_listening.on('127.0.0.1') }
  end

  describe file('/etc/postgresql/16/main/pg_hba.conf') do
    it { should exist }
    its(:content) { should match /^local\s+all\s+all\s+peer/ }
    its(:content) { should_not match /trust/ }
  end

  describe command('psql -U postgres -c "SELECT version();"') do
    its(:exit_status) { should eq 0 }
  end
end

Test Security Hardening

# spec/security/hardening_spec.rb
require 'spec_helper'

describe 'Security hardening' do
  # SSH hardening
  describe file('/etc/ssh/sshd_config') do
    its(:content) { should match /^PermitRootLogin no/ }
    its(:content) { should match /^PasswordAuthentication no/ }
    its(:content) { should match /^X11Forwarding no/ }
    its(:content) { should match /^MaxAuthTries [1-5]/ }
  end

  # Firewall
  describe iptables do
    it { should have_rule('-P INPUT DROP') }
    it { should have_rule('-A INPUT -p tcp --dport 22 -j ACCEPT') }
  end

  # No unnecessary services
  %w[telnet rsh rlogin].each do |svc|
    describe package(svc) do
      it { should_not be_installed }
    end
  end

  # File permissions
  describe file('/etc/shadow') do
    it { should be_mode 640 }
    it { should be_owned_by 'root' }
  end

  # Fail2ban
  describe service('fail2ban') do
    it { should be_enabled }
    it { should be_running }
  end
end

Spec Helper

# spec/spec_helper.rb
require 'serverspec'
require 'net/ssh'

set :backend, :ssh

host = ENV['TARGET_HOST']

options = Net::SSH::Config.for(host)
options[:user] ||= 'ansible'
options[:keys] ||= ['~/.ssh/id_rsa']

set :host, options[:host_name] || host
set :ssh_options, options
set :disable_sudo, false

Integrate with Ansible Workflow

# ansible/test-and-deploy.yml
---
- name: Deploy and test infrastructure
  hosts: webservers
  become: true
  roles:
    - webserver
    - security_hardening

  post_tasks:
    - name: Run Serverspec tests
      delegate_to: localhost
      ansible.builtin.command:
        cmd: "TARGET_HOST={{ inventory_hostname }} rake spec"
        chdir: "{{ playbook_dir }}/../"
      register: test_result

    - name: Display test results
      ansible.builtin.debug:
        msg: "{{ test_result.stdout_lines }}"

CI/CD Pipeline

# .github/workflows/test-infra.yml
name: Infrastructure Tests
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Ansible playbook
        run: ansible-playbook -i inventory ansible/site.yml
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
      - name: Install Serverspec
        run: gem install serverspec
      - name: Run tests
        run: TARGET_HOST=localhost rake spec

Run Tests

# Run all specs
rake spec

# Run specific spec
TARGET_HOST=webserver01 rspec spec/webserver/nginx_spec.rb

# Verbose output
TARGET_HOST=webserver01 rspec spec/webserver/nginx_spec.rb --format documentation

Serverspec vs Other Testing Tools

FeatureServerspecTestinfraInSpec
LanguageRuby/RSpecPython/pytestRuby DSL
LicenseMITApache 2.0Apache 2.0
ComplianceBasicBasicBuilt-in profiles
Ansible fitGoodExcellentGood

Troubleshooting

# SSH connection issues
TARGET_HOST=server01 rspec spec/ --format documentation 2>&1

# Missing gems
bundle install  # if using Gemfile

# Permission denied
# Ensure spec_helper.rb has correct SSH key path

Conclusion

Serverspec brings TDD to infrastructure. Write tests first, run Ansible to converge, validate with Serverspec. This catches drift and broken playbooks before they reach production.