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
| Feature | Serverspec | Testinfra | InSpec |
|---|---|---|---|
| Language | Ruby/RSpec | Python/pytest | Ruby DSL |
| License | MIT | Apache 2.0 | Apache 2.0 |
| Compliance | Basic | Basic | Built-in profiles |
| Ansible fit | Good | Excellent | Good |
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
Related Articles
- Ansible Testinfra — Validate Infrastructure
- Ansible InSpec Compliance Testing
- Ansible Dry Run Check and Diff Mode
- Ansible CI/CD Pipeline Integration
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.