Skip to content
Back to skills

Ansible

ASecurity

Configuring servers or VMs, deploying an app onto hosts, or reviewing playbooks — `implementer:iac`, `implementer:pipeline`

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 1, 2026
ai-agentspythongoshellbashdockerkubernetesterraformapidatabasesecurity

Works with

  • api

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add sharmapuneet1510/awesome-prompts --skill ansible --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Ansible?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Ansible
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/sharmapuneet1510-ansible/badge)](https://www.skillsdirectory.com/skills/sharmapuneet1510-ansible)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: ansible
description: Configuring servers or VMs, deploying an app onto hosts, or reviewing playbooks — `implementer:iac`, `implementer:pipeline`
---

# Ansible Skill — v1.0

## Quick Card

> Read this card first. Load a section below only when the task needs it.

| | |
|---|---|
| **Use when** | Configuring servers or VMs, deploying an app onto hosts, or reviewing playbooks — `implementer:iac`, `implementer:pipeline` |
| **Skip when** | Creating cloud resources (networks, databases, clusters) — use Terraform/OpenTofu and hand Ansible the hosts. Containers on Kubernetes — manifests or Helm |
| **Inputs** | Target hosts and groups, OS family, what each host must end up with, secrets and where they live |
| **Produces** | `ansible.cfg`, `collections/requirements.yml`, `inventories/<env>/`, `roles/<role>/`, `playbooks/site.yml`, `.ansible-lint` |
| **Steps** | 1. Layout (§1) → 2. Pin collections (§2) → 3. Inventory and variables (§3) → 4. Role with argument spec (§4) → 5. Secrets in Vault (§5) → 6. Lint, check, apply, apply again (§6) → 7. Roll out safely (§7) |
| **Done when** | `ansible-lint` passes the `production` profile; a second run reports `changed=0`; no secret appears in any output |
| **Senior defaults** | FQCN always (`ansible.builtin.copy`) · every task idempotent — `command`/`shell` only with `creates`, `removes`, or `changed_when` · restarts through handlers, never inline · `validate:` on every config file · `argument_specs.yml` so bad input fails before any change · secrets: Vault + `no_log: true` + `diff: false` · pin collection versions · `--check --diff` before every production run · `serial` + `max_fail_percentage` for fleets |
| **Load on demand** | §1 layout · §2 collections · §3 inventory · §4 role · §5 secrets · §6 test loop · §7 rollout · §8 pitfalls |
| **Run report** | `html_report_skill` — adds: Recap per host (ok / changed / failed) · Idempotence result · Lint result |
| **Pairs with** | `security_audit_skill`, `project_setup_skill`, `logger_skill` (log config deployed by roles) |

---

## 1. Layout

```text
infra/
├── ansible.cfg                      ← project-local config; beats ~/.ansible.cfg
├── collections/requirements.yml     ← pinned collections, installed into ./collections
├── inventories/
│   ├── staging/hosts.yml
│   └── production/
│       ├── hosts.yml
│       └── group_vars/all/
│           ├── main.yml             ← plain variables
│           └── vault.yml            ← encrypted: vault_* variables only
├── playbooks/site.yml
├── roles/app_service/
│   ├── defaults/main.yml            ← every variable the role reads, with a safe default
│   ├── meta/main.yml
│   ├── meta/argument_specs.yml      ← types, choices, required — validated before tasks run
│   ├── tasks/main.yml
│   ├── handlers/main.yml
│   ├── templates/
│   └── files/
└── .ansible-lint
```

One inventory directory per environment, so pointing at production is an
explicit `-i inventories/production`, never an accident.

```ini
[defaults]
inventory = inventories/lab/hosts.yml
roles_path = roles
collections_path = collections
interpreter_python = auto_silent
callback_result_format = yaml
retry_files_enabled = false

[diff]
always = false
```

## 2. Collections — Pinned, Local

```yaml
---
collections:
  - name: community.docker
    version: 5.3.0
```

```bash
ansible-galaxy collection install -r collections/requirements.yml -p collections
```

`ansible-core` ships only `ansible.builtin`. Everything else (`community.general`,
`ansible.posix`, `community.docker`, cloud collections) is a versioned
dependency — pin it as you would a library.

## 3. Inventory and Variables

```yaml
# inventories/lab/hosts.yml
---
all:
  children:
    app_servers:
      hosts:
        ans-target:
          ansible_connection: community.docker.docker
```

```yaml
# inventories/lab/group_vars/all/main.yml
---
app_service_port: 8080
app_service_log_level: info
app_service_api_token: "{{ vault_app_api_token }}"
```

Precedence, in the order that matters day to day (later wins):

| Where | Use for |
|---|---|
| `roles/<role>/defaults/main.yml` | The role's safe defaults — the only place a role's variables are introduced |
| `inventories/<env>/group_vars/all/` | Environment-wide values |
| `inventories/<env>/group_vars/<group>/` | Per group of hosts |
| `inventories/<env>/host_vars/<host>/` | One host's exceptions — keep rare |
| `-e` on the command line | One-off overrides; highest precedence, so never for normal config |

Avoid `roles/<role>/vars/main.yml` for anything a user should change: it beats
inventory variables and surprises everyone.

## 4. A Role

`ansible-lint`'s `production` profile requires role variables to carry the role
name as prefix (`var-naming[no-role-prefix]`) — the example first failed six
times on names like `app_port` until they became `app_service_port`.

**`defaults/main.yml`**

```yaml
---
app_service_user: app
app_service_config_dir: /etc/app
app_service_state_dir: /var/lib/app
app_service_port: 8080
app_service_log_level: info
app_service_packages:
  - jq
```

**`meta/argument_specs.yml`** — checked before the first task:

```yaml
---
argument_specs:
  main:
    short_description: Install and configure the app service
    options:
      app_service_port:
        type: int
        required: true
        description: TCP port the app listens on.
      app_service_log_level:
        type: str
        choices: [debug, info, warning, error]
        default: info
        description: Log level written to the config file.
      app_service_api_token:
        type: str
        required: true
        no_log: true
        description: Token the app uses to call the upstream API. Supply from Vault.
      app_service_user:
        type: str
        default: app
        description: System user that owns the app's files.
      app_service_config_dir:
        type: path
        default: /etc/app
        description: Directory for the config file.
      app_service_state_dir:
        type: path
        default: /var/lib/app
        description: Writable state directory.
      app_service_packages:
        type: list
        elements: str
        default: [jq]
        description: OS packages the app needs.
```

Verified: `-e app_service_log_level=verbose` failed before any change with
*"value of app_service_log_level must be one of: debug, info, warning, error, got: verbose"*.

**`tasks/main.yml`**

```yaml
---
- name: Check the port is unprivileged and valid
  ansible.builtin.assert:
    that:
      - app_service_port | int >= 1024
      - app_service_port | int <= 65535
    fail_msg: "app_service_port must be 1024-65535, got {{ app_service_port }}"
    quiet: true

- name: Install OS packages
  ansible.builtin.apt:
    name: "{{ app_service_packages }}"
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Create the app group
  ansible.builtin.group:
    name: "{{ app_service_user }}"
    system: true

- name: Create the app user
  ansible.builtin.user:
    name: "{{ app_service_user }}"
    group: "{{ app_service_user }}"
    system: true
    shell: /usr/sbin/nologin
    create_home: false

- name: Create directories
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: directory
    owner: "{{ item.owner }}"
    group: "{{ app_service_user }}"
    mode: "{{ item.mode }}"
  loop:
    - { path: "{{ app_service_config_dir }}", owner: root, mode: "0750" }
    - { path: "{{ app_service_state_dir }}", owner: "{{ app_service_user }}", mode: "0750" }

- name: Install the reload script
  ansible.builtin.copy:
    src: app-reload
    dest: /usr/local/bin/app-reload
    owner: root
    group: root
    mode: "0755"

- name: Render the config file
  ansible.builtin.template:
    src: app.toml.j2
    dest: "{{ app_service_config_dir }}/app.toml"
    owner: root
    group: "{{ app_service_user }}"
    mode: "0640"
    validate: python3 -c 'import sys, tomllib; tomllib.load(open(sys.argv[1], "rb"))' %s
  no_log: true
  diff: false
  notify: Restart app
```

**`handlers/main.yml`**

```yaml
---
- name: Restart app
  ansible.builtin.command: /usr/local/bin/app-reload
  changed_when: true
```

**`templates/app.toml.j2`**

```jinja
# {{ ansible_managed }}
[server]
port = {{ app_service_port | int }}
log_level = "{{ app_service_log_level }}"

[upstream]
api_token = "{{ app_service_api_token }}"
```

What each choice buys:

| Choice | Why |
|---|---|
| `assert` first | Fails with a clear message (`app_service_port must be 1024-65535, got 80` — verified) instead of a half-configured host |
| `apt` with `cache_valid_time` | Idempotent; does not refresh the package cache on every run |
| `validate:` on the template | A broken config never replaces a working one — the file is checked in a temp path first |
| `notify` → handler | The service restarts once, at the end, only if something changed |
| `no_log: true` + `diff: false` on the secret-bearing template | Neither the task result nor `--diff` output can print the token |
| Real service restart | `ansible.builtin.systemd_service: name=app state=restarted` in the handler (the example container has no systemd, so it used a script) |

## 5. Secrets

```bash
ansible-vault encrypt inventories/production/group_vars/all/vault.yml
ansible-playbook playbooks/site.yml --vault-password-file ~/.config/infra/vault_pass
```

- `vault.yml` holds only `vault_*` variables; `main.yml` maps them to real names
  (`app_service_api_token: "{{ vault_app_api_token }}"`), so a grep of
  `main.yml` shows every secret that exists without revealing any.
- The vault password comes from a file outside the repository, a password
  manager script, or the CI secret store — never committed.
- Any task that handles a secret gets `no_log: true`; any template that
  renders one also gets `diff: false`. Verified: across a dry run, two real
  runs, and a `--check --diff` run, the token appeared in no output.

## 6. The Test Loop

```bash
ansible-playbook playbooks/site.yml --syntax-check
ansible-lint                                             # profile: production
ansible-playbook playbooks/site.yml --check --diff       # what would change
ansible-playbook playbooks/site.yml                      # apply
ansible-playbook playbooks/site.yml                      # apply again: must be changed=0
```

Verified results on a Debian 13 target:

| Run | Recap |
|---|---|
| First apply | `ok=10 changed=7` (6 task changes + the handler) |
| Second apply | `ok=9 changed=0` — idempotent |
| `--check --diff` on the converged host | `changed=0` |
| `--check --diff` after editing the config by hand | `changed=1`, handler skipped — drift found without touching the host |

The second apply is the test. Any `changed` on it is a bug: a `command` without
`changed_when`, a template with a timestamp in it, a package with
`state: latest`.

For role-level CI across OS versions, Molecule runs the same
converge-then-idempotence sequence in containers.

## 7. Rolling Out to a Fleet

```yaml
- name: Update app servers without an outage
  hosts: app_servers
  serial: "25%"                 # a quarter of the fleet at a time
  max_fail_percentage: 0        # any failure stops the rollout
  pre_tasks:
    - name: Take the host out of the load balancer
      ansible.builtin.command: /usr/local/bin/lb-drain {{ inventory_hostname }}
      delegate_to: localhost
      changed_when: true
  roles:
    - role: app_service
  post_tasks:
    - name: Wait until the app answers
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}:{{ app_service_port }}/health"
      register: health
      until: health.status == 200
      retries: 30
      delay: 2
    - name: Put the host back
      ansible.builtin.command: /usr/local/bin/lb-enable {{ inventory_hostname }}
      delegate_to: localhost
      changed_when: true
```

Syntax-checked and linted (production profile); not executed — `lb-drain` and
`lb-enable` stand in for your load balancer's API or module.

`--limit` narrows a run to named hosts; `--start-at-task` resumes; tags let a
run touch only config (`--tags config`).

## 8. Pitfalls

| Symptom | Cause | Fix |
|---|---|---|
| `python3-apt must be installed to use check mode` | `apt` needs `python3-apt` on the target for `--check`; a real run auto-installs it | Run once for real, or install `python3-apt` in the base image — verified on a fresh Debian 13 host |
| Second run shows `changed` | Non-idempotent task | `creates`/`removes`/`changed_when`; no timestamps in templates; `state: present`, not `latest` |
| Variable ignored | Defined in `roles/…/vars/` or overridden by `-e` | Move to `defaults/`; check with `ansible-inventory --host <h>` |
| Secret printed in CI log | Missing `no_log`, or `--diff` on a template | `no_log: true` and `diff: false` on that task |
| `couldn't resolve module/action` | Collection not installed or wrong `collections_path` | `ansible-galaxy collection install -r collections/requirements.yml -p collections` |
| `Ansible requires blocking IO on stdin/stdout/stderr` | Run from a tool that gives Ansible non-blocking pipes | Redirect: `ansible-playbook … > out.log 2>&1 < /dev/null` |

## 9. Checklist

✅ Project-local `ansible.cfg`; one inventory directory per environment
✅ Collections pinned in `collections/requirements.yml`
✅ FQCN for every module
✅ Role variables prefixed, defaulted in `defaults/`, typed in `argument_specs.yml`
✅ `command`/`shell` only with `creates`/`removes`/`changed_when`
✅ Config files rendered with `validate:`; restarts via handlers
✅ Secrets in Vault; `no_log` + `diff: false` wherever they flow
✅ `ansible-lint` production profile passes
✅ `--check --diff` reviewed before production; second apply is `changed=0`
✅ Fleet changes use `serial` and `max_fail_percentage`

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…