Serverspec: Test-Driven Infrastructure with RSpec for Server Configuration
Serverspec lets you write RSpec tests for server infrastructure. Instead of logging into a box and manually checking whether nginx is running or a firewall rule is in place, you describe the expected state in code and run it as a test suite.
Originally created for testing Chef and Puppet configurations, Serverspec is tool-agnostic: you can use it with Ansible, raw shell scripts, or even manually provisioned servers. The test connects via SSH (or runs locally) and checks actual system state.
Why Serverspec Over InSpec?
Both test server state with RSpec-like syntax. The differences:
- Serverspec: simpler, lighter, SSH-centric, no profile system, used heavily in Chef/Test Kitchen
- InSpec: adds compliance profiles, inheritance, waivers, and
inspec.ymlmanifests; integrates with Chef Automate and compliance reporting
For straightforward "does my server match this specification" testing, Serverspec is less overhead. It's also the original — InSpec was inspired by it.
Installation
gem install serverspecOr with Bundler:
# Gemfile
source 'https://rubygems.org'
gem 'serverspec', '~> 2.42'
gem 'rake'Initializing a Serverspec Suite
serverspec-initThis prompts for your backend (SSH or exec) and target hosts. It generates:
spec/
├── spec_helper.rb
└── <hostname>/
└── sample_spec.rb
Rakefilespec_helper.rb
require 'serverspec'
require 'net/ssh'
# For remote SSH testing
set :backend, :ssh
if ENV['ASK_SUDO_PASSWORD']
begin
require 'highline/import'
rescue LoadError
fail "highline is not available. Try installing it."
end
set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
set :sudo_password, ENV['SUDO_PASSWORD']
end
host = ENV['TARGET_HOST']
options = Net::SSH::Config.for(host)
options[:user] ||= Etc.getlogin
set :host, options[:host_name] || host
set :ssh_options, options
set :request_pty, trueFor testing locally (post-converge in CI):
require 'serverspec'
set :backend, :execCore Resource Types
Serverspec provides resource types for every common system component:
Packages
describe package('nginx') do
it { should be_installed }
end
describe package('nginx') do
it { should be_installed.with_version('1.24.0') }
end
# Multiple packages
%w[git curl wget vim].each do |pkg|
describe package(pkg) do
it { should be_installed }
end
endServices
describe service('nginx') do
it { should be_enabled }
it { should be_running }
end
describe service('postgresql') do
it { should be_enabled }
it { should be_running }
it { should be_running.under('systemd') }
endPorts
describe port(80) do
it { should be_listening }
end
describe port(443) do
it { should be_listening }
its('protocols') { should include 'tcp' }
its('addresses') { should include '0.0.0.0' }
end
# Ensure a port is NOT open
describe port(22) do
it { should_not be_listening }
endFiles and Directories
describe file('/etc/nginx/nginx.conf') do
it { should exist }
it { should be_file }
it { should be_readable }
its(:content) { should match /worker_processes auto/ }
its(:content) { should match /gzip on/ }
its('mode') { should cmp '0644' }
its('owner') { should eq 'root' }
its('group') { should eq 'root' }
end
describe file('/var/log/nginx') do
it { should be_directory }
it { should be_writable.by('owner') }
its('owner') { should eq 'nginx' }
end
describe file('/etc/ssl/certs/server.crt') do
it { should exist }
it { should be_file }
its('mode') { should cmp '0644' }
endUsers and Groups
describe user('nginx') do
it { should exist }
it { should belong_to_group 'nginx' }
it { should have_home_directory '/var/cache/nginx' }
it { should have_login_shell '/sbin/nologin' }
end
describe group('deploy') do
it { should exist }
its('gid') { should be >= 1000 }
end
describe user('ubuntu') do
it { should belong_to_group 'sudo' }
it { should belong_to_group 'docker' }
endCommands
describe command('nginx -v') do
its(:exit_status) { should eq 0 }
its(:stderr) { should match /nginx\/1\.\d+/ }
end
describe command('psql --version') do
its(:exit_status) { should eq 0 }
its(:stdout) { should match /PostgreSQL 15/ }
end
describe command('systemctl is-active nginx') do
its(:stdout) { should match /active/ }
its(:exit_status) { should eq 0 }
endKernel and System
describe kernel_parameter('net.ipv4.ip_forward') do
its(:value) { should eq 0 }
end
describe kernel_parameter('vm.swappiness') do
its(:value) { should be <= 10 }
end
describe host('google.com') do
it { should be_resolvable }
end
describe interface('eth0') do
it { should exist }
it { should be_up }
its('speed') { should eq 1000 }
endFirewall and iptables
describe iptables do
it { should have_rule('-A INPUT -p tcp --dport 22 -j ACCEPT') }
it { should have_rule('-A INPUT -j DROP') }
end
# For systems using ufw
describe command('ufw status') do
its(:stdout) { should match /Status: active/ }
its(:stdout) { should match /22.*ALLOW/ }
its(:stdout) { should match /80.*ALLOW/ }
endCron Jobs
describe cron do
it { should have_entry '0 2 * * * /opt/backup/run.sh >> /var/log/backup.log 2>&1' }
end
describe cron do
it { should have_entry(/backup/).owned_by('root') }
endOrganizing Specs
Structure specs by role/component, not by host:
spec/
├── spec_helper.rb
├── roles/
│ ├── webserver_spec.rb
│ ├── database_spec.rb
│ └── monitoring_spec.rb
└── shared/
├── base_security_spec.rb
└── ntp_spec.rbUse shared examples for common checks:
# spec/shared/base_security_spec.rb
shared_examples 'a hardened server' do
describe package('fail2ban') do
it { should be_installed }
it { should be_running.under('systemd') }
end
describe package('unattended-upgrades') do
it { should be_installed }
end
describe kernel_parameter('kernel.randomize_va_space') do
its(:value) { should eq 2 }
end
describe port(22) do
its('addresses') { should_not include '0.0.0.0' }
end
end
# spec/roles/webserver_spec.rb
require 'spec_helper'
it_behaves_like 'a hardened server'
describe package('nginx') do
it { should be_installed }
endRakefile for Multi-Host Testing
# Rakefile
require 'rake'
require 'rspec/core/rake_task'
hosts = %w[web01 web02 db01 monitoring01]
task :spec => 'spec:all'
namespace :spec do
task :all => hosts.map { |h| "spec:#{h}" }
hosts.each do |host|
desc "Run serverspec for #{host}"
RSpec::Core::RakeTask.new(host) do |t|
ENV['TARGET_HOST'] = host
t.rspec_opts = '--format documentation --color'
t.pattern = "spec/roles/#{role_for(host)}_spec.rb"
end
end
end
def role_for(host)
case host
when /^web/ then 'webserver'
when /^db/ then 'database'
when /^monitoring/ then 'monitoring'
end
endRun:
rake spec # all hosts
rake spec:web01 # specific host
rspec spec/roles/webserver_spec.rb # directIntegration with Test Kitchen
Serverspec works as a Test Kitchen verifier via the kitchen-serverspec gem:
# Gemfile
gem 'kitchen-serverspec', '~> 0.7'# kitchen.yml
verifier:
name: serverspec
patterns:
- test/integration/default/serverspec/**/*_spec.rb# test/integration/default/serverspec/nginx_spec.rb
require 'serverspec'
set :backend, :exec # runs locally inside the container
describe package('nginx') do
it { should be_installed }
end
describe service('nginx') do
it { should be_running }
endSSH Configuration for Remote Testing
For testing remote servers in a staging environment:
# spec/spec_helper.rb for SSH multi-host
require 'serverspec'
require 'net/ssh'
set :backend, :ssh
set :request_pty, true
RSpec.configure do |c|
c.before :all do
set :host, ENV['TARGET_HOST']
set :ssh_options, {
user: 'deploy',
keys: [ENV.fetch('SSH_KEY_PATH', '~/.ssh/id_rsa')],
forward_agent: false,
paranoid: false # don't check host keys in CI
}
end
endFor servers behind a bastion/jump host:
set :ssh_options, {
user: 'deploy',
keys: ['~/.ssh/id_rsa'],
proxy: Net::SSH::Proxy::Jump.new('bastion.example.com')
}CI Integration
GitHub Actions
name: Serverspec
on:
push:
paths:
- 'roles/**'
- 'spec/**'
jobs:
serverspec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Provision test server (Docker)
run: |
docker run -d --name test-server \
--privileged \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
geerlingguy/docker-ubuntu2204-ansible
# Run your provisioner (Ansible example)
ansible-playbook -i "docker-test-server," \
-c docker site.yml
- name: Run Serverspec
run: |
TARGET_HOST=docker-test-server \
BACKEND=docker \
bundle exec rspec spec/roles/webserver_spec.rb \
--format documentationWinRM Backend for Windows
Serverspec supports Windows servers via WinRM:
# spec/spec_helper.rb for Windows
require 'serverspec'
require 'winrm'
set :backend, :winrm
set :os, family: 'windows'
endpoint = "http://#{ENV['TARGET_HOST']}:5985/wsman"
opts = {
endpoint: endpoint,
user: ENV['WINRM_USER'],
password: ENV['WINRM_PASSWORD'],
transport: :plaintext
}
winrm = WinRM::Connection.new(opts)
Specinfra.configuration.winrm = winrmWindows resource checks:
describe service('wuauserv') do
it { should be_installed }
it { should be_enabled }
end
describe file('C:/inetpub/wwwroot') do
it { should be_directory }
end
describe port(80) do
it { should be_listening }
endCustom Resources
For checks that don't map to built-in resources:
# spec/support/custom_resources.rb
module Serverspec::Type
class AppVersion < Base
def correct_version?(expected)
command_result = run_command('/opt/myapp/bin/myapp --version')
command_result.stdout.strip == expected
end
end
def app_version
AppVersion.new
end
end
RSpec::Matchers.define :have_version do |expected|
match do |resource|
resource.correct_version?(expected)
end
end
# Usage in spec:
describe app_version do
it { should have_version('2.4.1') }
endCommon Pitfalls
Don't test implementation, test behavior. Bad:
describe file('/etc/nginx/conf.d/cache.conf') do
it { should exist }
endGood:
describe port(80) do
it { should be_listening }
end
describe command('curl -s -o /dev/null -w "%{http_code}" http://localhost/') do
its(:stdout) { should eq '200' }
endSudo gotchas: Many checks require sudo. Set sudo_password or configure passwordless sudo for your test user.
Slow SSH connections: Group related tests into single describe blocks to reduce SSH roundtrips. Use --format progress instead of documentation in CI to reduce output volume.
Summary
Serverspec gives you executable documentation for server state. Write the spec first (it fails because the server isn't configured), write the provisioning code (Ansible role, Chef recipe, shell script), run the spec again — it passes. That's test-driven infrastructure.
The resource type library covers everything you'll typically configure: packages, services, ports, files, users, cron, kernel parameters, firewall rules. For anything else, command resources and custom types fill the gap. For compliance reporting and profiles, graduate to InSpec — but for pure server state testing, Serverspec remains the most direct tool available.