Serverspec: Test-Driven Infrastructure with RSpec for Server Configuration

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.yml manifests; 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 serverspec

Or with Bundler:

# Gemfile
source 'https://rubygems.org'

gem 'serverspec', '~> 2.42'
gem 'rake'

Initializing a Serverspec Suite

serverspec-init

This prompts for your backend (SSH or exec) and target hosts. It generates:

spec/
├── spec_helper.rb
└── <hostname>/
    └── sample_spec.rb
Rakefile

spec_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, true

For testing locally (post-converge in CI):

require 'serverspec'

set :backend, :exec

Core 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
end

Services

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') }
end

Ports

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 }
end

Files 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' }
end

Users 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' }
end

Commands

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 }
end

Kernel 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 }
end

Firewall 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/ }
end

Cron 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') }
end

Organizing 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.rb

Use 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 }
end

Rakefile 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
end

Run:

rake spec             # all hosts
rake spec:web01       # specific host
rspec spec/roles/webserver_spec.rb  # direct

Integration 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 }
end

SSH 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
end

For 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 documentation

WinRM 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 = winrm

Windows 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 }
end

Custom 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') }
end

Common Pitfalls

Don't test implementation, test behavior. Bad:

describe file('/etc/nginx/conf.d/cache.conf') do
  it { should exist }
end

Good:

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' }
end

Sudo 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.

Read more

Start now free