Skip to content

Real-World Migration Examples#

Practical examples demonstrating Chef-to-Ansible migrations using SousChef tools, from simple packages to complex enterprise patterns.

Example Structure#

Each example follows this pattern:

  1. Chef Code - Original Chef implementation
  2. Analysis - SousChef tool usage to understand the code
  3. Conversion - Automated conversion to Ansible
  4. Validation - Testing and verification
  5. Best Practices - Optimization and improvements

Example 1: Simple Package Installation#

Chef Recipe#

# recipes/nginx.rb
package 'nginx' do
  action :install
end

service 'nginx' do
  action [:enable, :start]
end

Analysis with SousChef#

Parse the recipe at recipes/nginx.rb and show me what resources it contains
souschef-cli recipe recipes/nginx.rb

Output:

Resource 1: package[nginx]
  Action: install
Resource 2: service[nginx]
  Action: enable, start

Conversion#

Generate an Ansible playbook from recipes/nginx.rb
souschef-cli convert package nginx --action install > tasks/nginx.yml
souschef-cli convert service nginx --action 'enable,start' >> tasks/nginx.yml

Generated Ansible:

---
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Enable and start nginx
  ansible.builtin.service:
    name: nginx
    enabled: true
    state: started

Validation#

# Validate the conversion
souschef-cli validate playbook --content "$(cat tasks/nginx.yml)"

# Test in development
ansible-playbook -i inventory/dev tasks/nginx.yml --check
ansible-playbook -i inventory/dev tasks/nginx.yml

# Verify idempotency
ansible-playbook -i inventory/dev tasks/nginx.yml --check

Best Practice: [YES] Direct conversion works perfectly for simple packages and services.


Example 2: Template with Variables#

Chef Recipe#

# recipes/web.rb
template '/etc/nginx/nginx.conf' do
  source 'nginx.conf.erb'
  owner 'root'
  group 'root'
  mode '0644'
  variables(
    worker_processes: node['nginx']['workers'],
    worker_connections: node['nginx']['connections'],
    server_name: node['nginx']['server_name']
  )
  notifies :reload, 'service[nginx]', :delayed
end

service 'nginx' do
  action :nothing
end

Chef Template (templates/nginx.conf.erb):

user nginx;
worker_processes <%= @worker_processes %>;

events {
    worker_connections <%= @worker_connections %>;
}

http {
    server {
        listen 80;
        server_name <%= @server_name %>;
    }
}

Conversion#

# Parse template and convert
souschef-cli template templates/nginx.conf.erb --format json > template_analysis.json

Generated Ansible:

# tasks/web.yml
- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
  notify: Reload nginx

# handlers/main.yml
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

Jinja2 Template (templates/nginx.conf.j2):

user nginx;
worker_processes {{ nginx_workers }};

events {
    worker_connections {{ nginx_connections }};
}

http {
    server {
        listen 80;
        server_name {{ nginx_server_name }};
    }
}

Variables (group_vars/all.yml):

nginx_workers: 4
nginx_connections: 1024
nginx_server_name: example.com


Example 3: Conditional Execution with Guards#

Chef Recipe#

# recipes/deploy.rb
execute 'deploy_application' do
  command '/opt/deploy.sh'
  user 'deploy'
  only_if { ::File.exist?('/opt/app/latest.tar.gz') }
  not_if { ::File.exist?('/opt/app/.deployed') }
end

file '/opt/app/.deployed' do
  action :create
  only_if { node['app']['environment'] == 'production' }
end

Conversion with Guard Handling#

Generated Ansible:

- name: Check for application archive
  ansible.builtin.stat:
    path: /opt/app/latest.tar.gz
  register: app_archive

- name: Check for deployment marker
  ansible.builtin.stat:
    path: /opt/app/.deployed
  register: deploy_marker

- name: Deploy application
  ansible.builtin.command:
    cmd: /opt/deploy.sh
  become: true
  become_user: deploy
  when:
    - app_archive.stat.exists
    - not deploy_marker.stat.exists

- name: Create deployment marker
  ansible.builtin.file:
    path: /opt/app/.deployed
    state: touch
    mode: '0644'
  when: app_environment == 'production'

Best Practice: [YES] Guards convert to stat checks + when conditions for clarity.


Example 4: Data Bag Integration#

Chef Recipe#

# recipes/database.rb
db_secrets = data_bag_item('secrets', 'database')

postgresql_user 'app_user' do
  password db_secrets['password']
  action :create
end

template '/etc/app/database.yml' do
  source 'database.yml.erb'
  variables(
    host: db_secrets['host'],
    username: db_secrets['username'],
    password: db_secrets['password']
  )
  sensitive true
end

Chef Data Bag (data_bags/secrets/database.json):

{
  "id": "database",
  "host": "db.example.com",
  "username": "app_user",
  "password": "secret123"
}

Conversion#

Convert the data bag at data_bags/secrets/database.json
to Ansible Vault
# Convert data bag to vars
souschef-cli databag-convert data_bags/secrets/database.json --vault

Generated Ansible:

# tasks/database.yml
- name: Create database user
  community.postgresql.postgresql_user:
    name: app_user
    password: "{{ db_password }}"
    state: present
  no_log: true

- name: Deploy database configuration
  ansible.builtin.template:
    src: database.yml.j2
    dest: /etc/app/database.yml
    mode: '0600'
  no_log: true

Ansible Vault (group_vars/all/vault.yml):

# Encrypted with: ansible-vault encrypt group_vars/all/vault.yml
db_host: db.example.com
db_username: app_user
db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653865...

Best Practice: [YES] Always use no_log: true for sensitive data tasks.


Example 5: Chef Search to Ansible Inventory#

Chef Recipe#

# recipes/haproxy.rb
backends = search(:node, "role:webserver AND chef_environment:#{node.chef_environment}")

template '/etc/haproxy/haproxy.cfg' do
  source 'haproxy.cfg.erb'
  variables(
    backends: backends.map { |n| n['ipaddress'] }
  )
  notifies :reload, 'service[haproxy]'
end

Conversion#

Convert Chef search "role:webserver AND chef_environment:production"
to Ansible dynamic inventory group named "webservers"

Generated Ansible:

# playbooks/haproxy.yml
- name: Configure HAProxy
  hosts: haproxy
  tasks:
    - name: Deploy HAProxy configuration
      ansible.builtin.template:
        src: haproxy.cfg.j2
        dest: /etc/haproxy/haproxy.cfg
        mode: '0644'
      notify: Reload haproxy

  handlers:
    - name: Reload haproxy
      ansible.builtin.service:
        name: haproxy
        state: reloaded

Jinja2 Template (templates/haproxy.cfg.j2):

global
    maxconn 4096

defaults
    mode http
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend http-in
    bind *:80
    default_backend webservers

backend webservers
    balance roundrobin
{% for host in groups['webservers'] %}
    server {{ host }} {{ hostvars[host]['ansible_host'] }}:80 check
{% endfor %}

Inventory (inventory/production/hosts.yml):

all:
  children:
    webservers:
      hosts:
        web01:
          ansible_host: 10.0.1.10
        web02:
          ansible_host: 10.0.1.11
        web03:
          ansible_host: 10.0.1.12
    haproxy:
      hosts:
        lb01:
          ansible_host: 10.0.1.20

Best Practice: [YES] Chef search converts to Ansible inventory groups with groups[] variable.


Example 6: Custom Resource Migration#

Chef Custom Resource#

# resources/app_deploy.rb
property :app_name, String, name_property: true
property :version, String, required: true
property :user, String, default: 'deploy'
property :environment, Hash, default: {}

action :deploy do
  directory "/opt/apps/#{new_resource.app_name}" do
    owner new_resource.user
    mode '0755'
    recursive true
  end

  remote_file "/opt/apps/#{new_resource.app_name}/app-#{new_resource.version}.tar.gz" do
    source "https://releases.example.com/#{new_resource.app_name}-#{new_resource.version}.tar.gz"
    owner new_resource.user
    mode '0644'
  end

  execute "extract_app" do
    command "tar xzf app-#{new_resource.version}.tar.gz"
    cwd "/opt/apps/#{new_resource.app_name}"
    user new_resource.user
  end
end

Chef Recipe Usage:

app_deploy 'myapp' do
  version '1.2.3'
  user 'app'
  environment(
    'DATABASE_URL' => 'postgresql://localhost/myapp'
  )
  action :deploy
end

Conversion to Ansible Role#

Role Structure:

roles/app_deploy/
├── defaults/
│   └── main.yml
├── tasks/
│   └── main.yml
└── templates/
    └── env.j2

defaults/main.yml:

app_deploy_user: deploy
app_deploy_base_path: /opt/apps
app_deploy_environment: {}

tasks/main.yml:

- name: Create application directory
  ansible.builtin.file:
    path: "{{ app_deploy_base_path }}/{{ app_name }}"
    state: directory
    owner: "{{ app_deploy_user }}"
    mode: '0755'

- name: Download application archive
  ansible.builtin.get_url:
    url: "https://releases.example.com/{{ app_name }}-{{ app_version }}.tar.gz"
    dest: "{{ app_deploy_base_path }}/{{ app_name }}/app-{{ app_version }}.tar.gz"
    owner: "{{ app_deploy_user }}"
    mode: '0644'

- name: Extract application
  ansible.builtin.unarchive:
    src: "{{ app_deploy_base_path }}/{{ app_name }}/app-{{ app_version }}.tar.gz"
    dest: "{{ app_deploy_base_path }}/{{ app_name }}"
    remote_src: true
    owner: "{{ app_deploy_user }}"

Playbook Usage:

- name: Deploy application
  hosts: app_servers
  roles:
    - role: app_deploy
      vars:
        app_name: myapp
        app_version: 1.2.3
        app_deploy_user: app
        app_deploy_environment:
          DATABASE_URL: postgresql://localhost/myapp

Best Practice: [YES] Custom resources → Ansible roles with similar interface.


Example 7: InSpec to Ansible Testing#

Chef InSpec Profile#

# controls/nginx.rb
control 'nginx-01' do
  impact 1.0
  title 'Verify nginx installation'
  desc 'Ensure nginx is installed and configured correctly'

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

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

  describe file('/etc/nginx/nginx.conf') do
    it { should exist }
    it { should be_file }
    its('owner') { should eq 'root' }
    its('mode') { should cmp '0644' }
  end

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

Conversion#

Convert InSpec profile at tests/inspec/nginx to Ansible assert tasks
souschef-cli inspec-convert tests/inspec/nginx --format ansible_assert

Generated Ansible Tests:

# tests/verify.yml
- name: Verify nginx installation
  hosts: webservers
  gather_facts: true
  tasks:
    - name: Check nginx package
      ansible.builtin.package_facts:

    - name: Assert nginx is installed
      ansible.builtin.assert:
        that:
          - "'nginx' in ansible_facts.packages"
        fail_msg: "nginx package is not installed"
        success_msg: "nginx package is installed"

    - name: Get nginx service status
      ansible.builtin.service_facts:

    - name: Assert nginx service is enabled and running
      ansible.builtin.assert:
        that:
          - ansible_facts.services['nginx.service'].state == 'running'
          - ansible_facts.services['nginx.service'].status == 'enabled'
        fail_msg: "nginx service is not running or enabled"

    - name: Check nginx config file
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: nginx_conf

    - name: Assert nginx config exists with correct permissions
      ansible.builtin.assert:
        that:
          - nginx_conf.stat.exists
          - nginx_conf.stat.isreg
          - nginx_conf.stat.pw_name == 'root'
          - nginx_conf.stat.mode == '0644'
        fail_msg: "nginx.conf not found or has incorrect permissions"

    - name: Check nginx is listening on port 80
      ansible.builtin.wait_for:
        port: 80
        timeout: 5

Example 8: Habitat to Docker Migration#

Habitat Plan#

# habitat/plan.sh
pkg_name=myapp
pkg_origin=example
pkg_version="1.0.0"
pkg_maintainer="DevOps Team <devops@example.com>"
pkg_license=("Apache-2.0")
pkg_upstream_url="https://example.com/myapp"

pkg_deps=(
  core/node
  core/nginx
)

pkg_build_deps=(
  core/git
)

pkg_exports=(
  [port]=http.port
)

pkg_exposes=(port)

do_build() {
  npm install --production
  npm run build
}

do_install() {
  cp -r dist/* "${pkg_prefix}/"
  cp -r node_modules "${pkg_prefix}/"
}

Conversion#

Convert Habitat plan at habitat/plan.sh to Dockerfile
using node:18-alpine as base image

Generated Dockerfile:

# Multi-stage build for minimal image size
FROM node:18-alpine AS builder

WORKDIR /build

# Install build dependencies
RUN apk add --no-cache git

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm install --production

# Copy source
COPY . .

# Build application
RUN npm run build

# Production stage
FROM node:18-alpine

# Install nginx
RUN apk add --no-cache nginx

# Create non-root user
RUN addgroup -g 1001 app && \
    adduser -D -u 1001 -G app app

WORKDIR /app

# Copy built application
COPY --from=builder --chown=app:app /build/dist ./dist
COPY --from=builder --chown=app:app /build/node_modules ./node_modules

# Configure nginx
COPY nginx.conf /etc/nginx/nginx.conf

# Expose port
EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

# Switch to non-root user
USER app

# Start application
CMD ["node", "dist/server.js"]

Generated docker-compose.yml:

version: '3.8'

services:
  myapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - NODE_ENV=production
      - PORT=8080
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
    restart: unless-stopped
    networks:
      - app_network

networks:
  app_network:
    driver: bridge

Example 9: AWX Job Template Generation#

Chef Cookbook#

Starting with a database cookbook containing installation and configuration recipes.

Conversion#

Generate an AWX job template from examples/database cookbook
named "Deploy PostgreSQL Database"

Generated AWX Job Template (JSON):

{
  "name": "Deploy PostgreSQL Database",
  "description": "Converted from Chef cookbook: database",
  "job_type": "run",
  "inventory": "Production",
  "project": "Ansible Playbooks",
  "playbook": "database/site.yml",
  "credential": "SSH - Production",
  "verbosity": 1,
  "extra_vars": {
    "postgresql_version": "14",
    "postgresql_listen_addresses": "*",
    "postgresql_max_connections": 100
  },
  "ask_variables_on_launch": true,
  "ask_limit_on_launch": true,
  "survey_enabled": true,
  "survey_spec": {
    "name": "Database Deployment Survey",
    "description": "Configure PostgreSQL deployment parameters",
    "spec": [
      {
        "question_name": "PostgreSQL Version",
        "question_description": "PostgreSQL major version to install",
        "required": true,
        "type": "multiplechoice",
        "variable": "postgresql_version",
        "choices": ["12", "13", "14", "15"],
        "default": "14"
      },
      {
        "question_name": "Max Connections",
        "question_description": "Maximum number of client connections",
        "required": true,
        "type": "integer",
        "variable": "postgresql_max_connections",
        "min": 20,
        "max": 1000,
        "default": 100
      }
    ]
  }
}

AWX CLI Usage:

# Create job template
awx job_templates create \
  --name "Deploy PostgreSQL Database" \
  --job_type run \
  --inventory "Production" \
  --project "Ansible Playbooks" \
  --playbook "database/site.yml" \
  --extra_vars @job_template_vars.json


Common Patterns & Solutions#

Pattern: Iterating Over Arrays#

Chef:

node['packages'].each do |pkg|
  package pkg do
    action :install
  end
end

Ansible:

- name: Install packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop: "{{ packages }}"

Pattern: Platform-Specific Actions#

Chef:

case node['platform']
when 'ubuntu', 'debian'
  package 'apache2'
when 'centos', 'redhat'
  package 'httpd'
end

Ansible:

- name: Install web server (Debian)
  ansible.builtin.apt:
    name: apache2
    state: present
  when: ansible_os_family == 'Debian'

- name: Install web server (RedHat)
  ansible.builtin.yum:
    name: httpd
    state: present
  when: ansible_os_family == 'RedHat'

Pattern: File Ownership and Permissions#

Chef:

file '/opt/app/config.yaml' do
  owner 'app'
  group 'app'
  mode '0600'
  content lazy { Chef::JSONCompat.to_json_pretty(node['app']['config']) }
end

Ansible:

- name: Create application config
  ansible.builtin.copy:
    content: "{{ app_config | to_nice_yaml }}"
    dest: /opt/app/config.yaml
    owner: app
    group: app
    mode: '0600'


Troubleshooting Guide#

Issue: Complex Ruby Logic Doesn't Convert#

Problem: Chef recipe contains complex Ruby code that can't be directly converted.

Solution:

# Chef - Complex Ruby logic
ruby_block 'complex_calculation' do
  block do
    result = node['values'].map { |v| v * 2 }.sum
    node.run_state['calculated_value'] = result
  end
end

Ansible - Use set_fact:

- name: Perform complex calculation
  ansible.builtin.set_fact:
    calculated_value: "{{ values | map('int') | map('multiply', 2) | sum }}"

Or create custom module for very complex logic.

Issue: Notification Chains#

Problem: Complex notification dependencies in Chef.

Chef:

template '/etc/app/config.yml' do
  notifies :restart, 'service[app]', :delayed
  notifies :run, 'execute[reload_cache]', :immediately
end

execute 'reload_cache' do
  command '/usr/bin/reload_cache.sh'
  action :nothing
end

service 'app' do
  action :nothing
end

Ansible - Use handlers and flush:

- name: Deploy configuration
  ansible.builtin.template:
    src: config.yml.j2
    dest: /etc/app/config.yml
  notify:
    - Reload cache
    - Restart app

- name: Flush handlers if config changed
  meta: flush_handlers

# handlers/main.yml
- name: Reload cache
  ansible.builtin.command: /usr/bin/reload_cache.sh

- name: Restart app
  ansible.builtin.service:
    name: app
    state: restarted

Issue: Chef Attributes Precedence#

Problem: Complex attribute precedence in Chef doesn't map directly.

Chef:

default['app']['port'] = 8080
override['app']['port'] = 9090  # Takes precedence

Ansible - Use variable precedence:

# group_vars/all.yml (lowest precedence)
app_port: 8080

# group_vars/production.yml (higher precedence)
app_port: 9090

# host_vars/app01.yml (highest precedence)
app_port: 9091


Performance Optimization#

Tip 1: Batch Package Installations#

Before:

- name: Install package 1
  ansible.builtin.package:
    name: nginx
    state: present

- name: Install package 2
  ansible.builtin.package:
    name: git
    state: present

After:

- name: Install packages
  ansible.builtin.package:
    name:
      - nginx
      - git
    state: present

Tip 2: Use Blocks for Error Handling#

- name: Deploy with rollback capability
  block:
    - name: Deploy new version
      ansible.builtin.copy:
        src: app-v2.jar
        dest: /opt/app/app.jar
        backup: true
      register: deploy_result

    - name: Restart application
      ansible.builtin.service:
        name: app
        state: restarted

  rescue:
    - name: Rollback on failure
      ansible.builtin.copy:
        src: "{{ deploy_result.backup_file }}"
        dest: /opt/app/app.jar
        remote_src: true

    - name: Restart with old version
      ansible.builtin.service:
        name: app
        state: restarted

Tip 3: Parallel Execution with Strategy#

- name: Deploy to web servers
  hosts: webservers
  strategy: free  # Don't wait for all hosts
  tasks:
    - name: Deploy application
      ansible.builtin.copy:
        src: app.jar
        dest: /opt/app/

Best Practices Checklist#

  • Use validate_conversion after each major conversion
  • Test idempotency (run playbook twice, second run should show no changes)
  • Use no_log: true for tasks handling sensitive data
  • Organise playbooks into roles for reusability
  • Use handlers for service restarts
  • Implement proper error handling with blocks
  • Tag tasks for selective execution
  • Use check_mode for dry runs
  • Document variable requirements in README
  • Store secrets in Ansible Vault
  • Use inventory groups instead of hardcoded hosts
  • Implement health checks after deployments


Getting Additional Help#

If you encounter patterns not covered here:

  1. Search the documentation for similar patterns
  2. Use SousChef's AI assistant to analyze your specific code
  3. Check Ansible docs for module equivalents
  4. Test incrementally - convert one resource at a time
  5. Contribute back - share your solutions with the community

Share Your Examples

Have a complex migration pattern you solved? Consider contributing it to the documentation! See Contributing for details.