ansible.builtin.command Breaks kubectl exec with Nested Quoting

Mar 25, 2026

I was running Vault commands inside a pod via Ansible. The task used ansible.builtin.command with a kubectl exec invocation. It worked for simple commands but failed silently on anything using environment variables inside the exec.

The fix was switching to ansible.builtin.shell. Here’s why they behave differently and when it matters.


The Task

- name: Enable KV v2 secrets engine
  ansible.builtin.command:
    cmd: >-
      kubectl exec -n vault vault-0 --
      sh -c "VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN={{ vault_root_token }}
      vault secrets enable -path=secret kv-v2"

This fails. The error from kubectl:

error: unknown flag: --VAULT_ADDR=http://127.0.0.1:8200

Or in some versions, the sh -c argument gets split and the env vars are treated as separate positional arguments to sh, which ignores them silently.

Why This Happens

ansible.builtin.command does not invoke a shell. It passes arguments directly to execv() — the same way Python’s subprocess.run(args, shell=False) works. The argument string is split on whitespace by Ansible before passing to the OS.

The >- YAML block scalar gives you a single string. Ansible splits it by whitespace. The result:

["kubectl", "exec", "-n", "vault", "vault-0", "--",
 "sh", "-c", "VAULT_ADDR=http://127.0.0.1:8200", "VAULT_TOKEN=...",
 "vault", "secrets", "enable", "-path=secret", "kv-v2"]

sh -c takes the next argument as the script and treats remaining arguments as positional parameters ($0, $1, …). So sh gets "VAULT_ADDR=http://127.0.0.1:8200" as its script — a variable assignment that sets nothing useful — and ignores VAULT_TOKEN=... and the actual vault command.

The double quotes in YAML don’t preserve shell quoting semantics. They’re just YAML string delimiters. By the time Ansible processes the string, the quotes are gone.

The Fix

- name: Enable KV v2 secrets engine
  ansible.builtin.shell:
    cmd: >-
      kubectl exec -n vault vault-0 --
      sh -c "VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN={{ vault_root_token }}
      vault secrets enable -path=secret kv-v2"

ansible.builtin.shell passes the entire string to /bin/sh -c. The shell handles the quoting. The double-quoted argument to sh -c is treated as a single token, which is exactly what kubectl exec -- sh -c "..." requires.

When to Use Each

ansible.builtin.command — for commands where you control every argument and there’s no shell evaluation needed. kubectl get pods -n mynamespace. helm repo add. No pipes, no redirects, no env var assignment inside the command.

ansible.builtin.shell — for anything involving:

The Ansible docs recommend command over shell where possible because shell introduces injection risks if any variable content is user-supplied. That’s a valid concern for general automation. For internal infrastructure tasks with known-safe values (Vault tokens, fixed paths), shell is the right choice.

The Idempotency Problem

One more thing: shell tasks always show changed in Ansible output unless you add changed_when. For Vault operations that are idempotent in effect (enabling an already-enabled engine returns an error), add:

- name: Enable KV v2 secrets engine
  ansible.builtin.shell:
    cmd: >-
      kubectl exec -n vault vault-0 --
      sh -c "VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN={{ vault_root_token }}
      vault secrets enable -path=secret kv-v2"
  register: result
  failed_when: result.rc != 0 and 'path is already in use' not in result.stderr
  changed_when: result.rc == 0

failed_when makes the task succeed if the engine is already enabled. changed_when marks it as changed only on the first run (when the enable actually happens), so re-runs show ok instead of changed.

Takeaway

ansible.builtin.command splits on whitespace before execution. Nested shell quoting (sh -c "...") requires the outer shell to handle token grouping — which only ansible.builtin.shell provides. Use shell for any kubectl exec with environment variables or compound commands inside the exec target.