Keeping secrets out of logs & artifacts

no_log, --diff leaks, and callback plugins.

Intermediate12 min · lesson 4 of 12

You did the hard part already. The database password lives in an encrypted vault file, the vault password lives in a password manager, and nothing sensitive sits in git. Then you run the playbook, and the credential prints on screen in clear text, lands in a log file on the control node, gets copied into a CI (continuous integration, the system that runs your pipelines) job log the whole company can read, and sits in that archive for ninety days. You locked the safe and then read the combination out loud with the tape recorder running. Encryption protects the file at rest. It does nothing at all for the output.

Every Line You See Comes From a Plugin

Ansible does not print anything by itself. A busy kitchen has a pass, the counter where one person takes each finished plate from the cooks and decides what gets called out to the room. Every line on your terminal comes off that same kind of counter. It is produced by a **callback plugin**, a small piece of code that receives structured events (task started, host succeeded, host failed, file changed) and decides how to render them. ansible-core ships a handful: default (the familiar coloured output), minimal, oneline and tree. You pick the one that writes to your screen with stdout_callback in ansible.cfg, and you can run others alongside it for timings and notifications. Everything below is ansible-core 2.21 against modern Linux hosts over SSH (secure shell, the encrypted remote-login protocol).

That matters because the callback is the single choke point every output sink hangs off. Set log_path and the same rendered lines get written to a file on the control node. Run the playbook under AWX or AAP (Ansible Automation Platform, the web interface and API that Red Hat builds on top of ansible-core; AWX is its free upstream project) and every event is written into a PostgreSQL database, kept for months until a cleanup job trims it, readable by anyone with view access to that job template. Run it in a pipeline and the same text becomes a job log. GitHub Actions keeps job logs and build artifacts for ninety days by default.

ansible.cfg
[defaults]
inventory = inventory/production
stdout_callback = default
log_path = /var/log/ansible/run.log

That log file is ordinary text, created with your umask (the default permission mask your shell stamps on new files), which normally means mode 0644 and readable by every account on the control node. Ansible never tightens it for you. Its lines look like this.

output
2026-07-21 09:14:02,118 p=48211 u=deploy n=ansible INFO| TASK [Render the app config] ***
2026-07-21 09:14:03,402 p=48211 u=deploy n=ansible INFO| changed: [web1.example.com]

Pipeline platforms do offer masking, and it helps less than people assume. GitHub Actions replaces registered secret values with asterisks, GitLab does the same for masked variables, and both work by matching the exact literal string. Ansible prints results as JSON (JavaScript Object Notation, the structured text format modules return), and JSON escapes characters. A password containing a backslash or a quote comes out slightly different from the string the masker is hunting for, and the mask silently misses. Base64 encode the value, wrap it across lines, or bury it in a URL and the mask misses too. It is a safety net with holes you cannot see from the outside.

What no_log Actually Silences

no_log: true is tape over the camera lens for one specific shot. The scene still happens, the actors still say their lines, you only stop the recording. Mechanically: when a task finishes on a host, Ansible assembles a result dictionary. Before that dictionary reaches any callback, it checks the task's no_log setting, and if it is true the whole dictionary is thrown away and replaced with a stub carrying one key. Six fields survive, because the run would be unreadable otherwise: changed, attempts, retries, warnings, deprecations and exception.

tasks/debug.yml
- name: Show the rendered application config
ansible.builtin.debug:
var: app_config
no_log: true
output
TASK [Show the rendered application config] ************************************
ok: [web1.example.com] => {
"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
}

That sentence is the one you learn to recognise on sight. Because the swap happens upstream of every callback, one keyword covers your terminal, the log_path file, the AWX event stream and the pipeline job log at the same moment. A note on when you will see it at all: at default verbosity a successful task prints ok: [host] and nothing else, so the censored stub only appears from -v upward. Failures and debug tasks dump their result whatever the verbosity, which is why the example above shows it with no flags at all.

There is one thing no_log protects on the managed host, and hardly anyone knows about it. Every module writes a line into the target's own system journal as it starts, naming itself and listing the arguments it was handed. no_log: true stops that line being written. Here is what an ordinary run leaves behind on the target.

terminal
journalctl --since '10 min ago' --no-pager | grep 'Invoked with'
output
Jul 21 09:14:03 web1 ansible-ansible.builtin.uri[4412]: Invoked with url=https://api.example.com/v1/nodes?token=dpl_9f3a7c21b84e0d5f method=POST body_format=json status_code=[200] validate_certs=True

That line is trimmed; the real one lists every parameter, defaults included. Two related behaviours sit next to it. A module that declares a parameter sensitive writes NOT_LOGGING_PARAMETER there instead of the value, and a parameter that merely looks like a password by name gets NOT_LOGGING_PASSWORD plus a warning aimed at the module author. If you would rather no managed host ever kept these lines, no_target_syslog = True under [defaults] turns the target-side logging off fleet-wide.

Where a task result ends up
Rendered by callbacks
Your terminal
whatever stdout_callback is set to
log_path file
mode 0644 by default
AWX / AAP job events
stored in Postgres for months
CI job log
90-day default retention
Lives on the managed host
The target's journal
one Invoked with line per module
/proc/<pid>/cmdline
any local account can read it
Module payload in ~/.ansible/tmp
kept if keep_remote_files is on
The file you rendered
owner and mode are your job
Still holds the plaintext
The registered variable
register keeps the uncensored result
A cacheable set_fact
plain text if fact_caching persists
-e on the command line
shell history and controller ps output
no_log replaces the result before any callback runs, so it covers the whole left column at once. In the middle column it stops the journal line and nothing else. It reaches nothing on the right.

A Token in a URL, Leaked at One -v

Here is a task that survives code review without a comment. It registers the node with an internal deploy API (application programming interface, the service other machines call over the network), and it authenticates by putting a token in the query string.

register.yml
- name: Register the node with the deploy API
ansible.builtin.uri:
url: "https://api.example.com/v1/nodes?token={{ deploy_api_token }}"
method: POST
body_format: json
body:
hostname: "{{ inventory_hostname }}"
register: node_reg
terminal
ansible-playbook -i inventory/production register.yml -v
output
TASK [Register the node with the deploy API] ************************************
ok: [web1.example.com] => {"changed": false, "connection": "close", "content_type": "application/json", "elapsed": 0, "json": {"id": 4471, "status": "registered"}, "msg": "OK (98 bytes)", "redirected": false, "status": 200, "url": "https://api.example.com/v1/nodes?token=dpl_9f3a7c21b84e0d5f"}

One -v and the token is public. Note where it came from. The url key is not something Ansible bolted on; it is the uri module's own documented return value, always returned, and no argument spec can mark it sensitive because it is a result field rather than a parameter. The usual advice to keep -vvv out of pipelines is therefore half a fix. Verbosity one is already enough. Verbosity zero spares your terminal and leaves the value sitting inside node_reg waiting for a later task to print it, and sitting in the target's journal from the section above.

There used to be a second copy of the token on the same screen, in an invocation block replaying the exact arguments handed to the module. Older ansible-core attached that block to every result and the callbacks merely hid it below -vvv, which meant anything storing raw events kept it even at verbosity zero. On 2.21 it is off unless you ask for it with inject_invocation = True under [defaults] (or ANSIBLE_INJECT_INVOCATION=1). Find out which side of that change your controller sits on, because plenty of execution environments still run older cores.

Two changes close this. Move the token out of the URL and into a header, which also keeps it out of the API server's access log and out of every proxy in between, then put no_log: true on the task.

register.yml
- name: Register the node with the deploy API
ansible.builtin.uri:
url: "https://api.example.com/v1/nodes"
method: POST
headers:
Authorization: "Bearer {{ deploy_api_token }}"
body_format: json
body:
hostname: "{{ inventory_hostname }}"
status_code: [200, 201]
register: node_reg
no_log: true
- name: Report the outcome without the payload
ansible.builtin.debug:
msg: "registration status={{ node_reg.status }}"
terminal
ansible-playbook -i inventory/production register.yml -v
output
TASK [Register the node with the deploy API] ************************************
ok: [web1.example.com] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}
TASK [Report the outcome without the payload] ***********************************
ok: [web1.example.com] => {
"msg": "registration status=201"
}

Do not drop the no_log because you moved the token into a header. uri declares url_password sensitive and says nothing about headers, so without no_log the whole Authorization line goes into the target's journal and into the module payload written under the remote user's home directory. The header move buys you protection outside Ansible. no_log buys you protection inside it. Take both.

--diff Prints the File You Were Careful About

--diff makes Ansible show the before and after of every file it changes, and it is one of the better habits in the tool. It is also a credential printer. If a template renders a config file that contains a password, the diff contains that password, in clear text, in exactly the change-review output you were about to paste into a ticket.

terminal
ansible-playbook -i inventory/production site.yml --diff --limit web1.example.com
output
TASK [Render the app config] ***************************************************
--- before: /etc/app/app.ini
+++ after: /home/deploy/.ansible/tmp/ansible-local-9917n5g_/tmp3b1kq8xw/app.ini.j2
@@ -1,6 +1,6 @@
[database]
host = db1.internal
user = app_rw
-password = PrevRotation-2025-11
+password = 8f2Qz-Rk4vTn-Lp0Ws-Yh6Bd
[cache]
changed: [web1.example.com]

no_log: true on that template task does stop this, and it stops it twice over. The action plugin blanks the before and after text and swaps in [[ Diff output has been hidden because 'no_log: true' was specified for this result ]], and the censoring step then drops the diff key from the result before any callback sees it. Now the honest trade-off. You lose the change review for that file completely and permanently, and all you get back is the word changed. The practical answer is to split the file: render the reviewable structure from one template and the two lines that carry credentials from a second, tiny one, then put no_log only on the second. You keep the diff where it earns its keep and lose it only where it bites.

Other People Control Your Verbosity and Your Diff
Your playbook does not get to decide whether --diff is on. always = True under the [diff] section of ansible.cfg, or ANSIBLE_DIFF_ALWAYS=1 in the environment, turns it on for every task without touching a line of your code. Read those names carefully, because the obvious guesses are wrong: the section is [diff] and not [defaults], and the variable is ANSIBLE_DIFF_ALWAYS and not ANSIBLE_DIFF. In AWX and AAP the same switch is a checkbox on the job template called **Show Changes**, sitting next to a **Verbosity** dropdown that runs from 0 to 5. A third one catches people out: display_args_to_stdout = True under [defaults] prints each task's arguments in the task header at any verbosity, including zero. A colleague chasing an unrelated bug can flip any of these, rerun your playbook, and publish the credential into a job record kept for months. no_log: true on the task is the only control that survives another person's settings.

The Registered Variable Keeps the Plaintext

This is the part that catches experienced people. no_log censors the copy that goes to the callbacks. It does not touch the copy that register stores. That variable holds the real, complete result, and the next task that prints it prints all of it.

deploy.yml
- name: Mint a short-lived deploy token
ansible.builtin.uri:
url: https://auth.example.com/oauth/token
method: POST
body_format: form-urlencoded
body:
grant_type: client_credentials
client_id: "{{ deploy_client_id }}"
client_secret: "{{ deploy_client_secret }}"
register: token_result
no_log: true
- name: Quick sanity check before the deploy
ansible.builtin.debug:
var: token_result
output
TASK [Mint a short-lived deploy token] ******************************************
ok: [web1.example.com]
TASK [Quick sanity check before the deploy] *************************************
ok: [web1.example.com] => {
"token_result": {
"changed": false,
"elapsed": 0,
"json": {
"access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkZXBsb3ktYm90In0.c2lnbmF0dXJl",
"expires_in": 3600,
"token_type": "Bearer"
},
"msg": "OK (312 bytes)",
"status": 200
}
}

Two tasks, one careful and one careless, and the careless one wins. That first line is the entire return on your no_log at default verbosity: one word. The dump below it is trimmed to the interesting keys, and the response body came through untouched, because Ansible has no idea that a key called access_token means anything. The client_secret is missing for a duller reason than you might hope, which is that this core does not attach module arguments to results at all by default. Switch inject_invocation on and it reappears, because uri declares url_password sensitive and says nothing about body. The rule that follows is narrow and easy to hold in your head. Never debug: var: a whole registered result. Name the one field you need, print that, and let the rest stay in memory.

Shell, Command, and the Process Table

The shell and command modules build a command line and hand it to the target's shell. On Linux the arguments of a running process are readable by every local account through /proc/<pid>/cmdline, which is exactly what ps reads. It is a parcel with the contents written on the outside of the box. For as long as the command runs, the password is visible to anyone with a shell on that host, including the low-privilege service account you worked hard to keep unprivileged.

tasks/artifacts.yml
- name: Push the release manifest to the artifact store
ansible.builtin.shell: >
curl -sf -u svc-deploy:{{ artifact_password }}
-T /srv/build/manifest.json
https://artifacts.example.com/incoming/
terminal
ps -eo pid,user,args | grep '[c]url'
output
4417 deploy /bin/sh -c curl -sf -u svc-deploy:R0tate-Me-2026 -T /srv/build/manifest.json https://artifacts.example.com/incoming/
4419 deploy curl -sf -u svc-deploy:R0tate-Me-2026 -T /srv/build/manifest.json https://artifacts.example.com/incoming/

no_log: true does less here than you want and more than the folklore says. It hides the cmd key and the _raw_params argument from your output, and it stops the journal line on the target, so put it on the task regardless. What it cannot touch is the process table, because that exposure belongs to the operating system on the far side of the connection.

The advice you will read everywhere is to move the secret into environment:, on the grounds that /proc/<pid>/environ is readable only by the process owner and root. The second half of that is true. The first half is wrong for Ansible on Linux, and it is worth checking yourself rather than taking my word for it. Ansible has no side channel for environment values here. It builds a string like ARTIFACT_PASSWORD='R0tate-Me-2026', glues it onto the front of the command it runs on the target, and executes the whole thing through the remote shell. Run ps again and the value is right there on the wrapper process, one line higher up the tree than before. The controller prints the same string in its SSH: EXEC line at -vvv. You moved the leak, you did not close it.

What does close it is keeping the secret off every command line. There are two ways. Feed it on standard input, which command and shell support directly through the stdin parameter.

tasks/registry.yml
- name: Log the node in to the internal registry
ansible.builtin.command:
cmd: docker login registry.example.com --username svc-deploy --password-stdin
stdin: "{{ registry_password }}"
no_log: true

Better still, use a module with a real credential parameter. Module arguments ride inside the generated payload rather than on a command line, and a module can declare which of its parameters are sensitive, which is something _raw_params on shell can never do because Ansible cannot know which slice of your string is the secret. ansible.builtin.uri marks url_password that way, so the same upload becomes this.

tasks/artifacts.yml
- name: Push the release manifest to the artifact store
ansible.builtin.uri:
url: https://artifacts.example.com/incoming/manifest.json
method: PUT
url_username: svc-deploy
url_password: "{{ artifact_password }}"
force_basic_auth: true
src: /srv/build/manifest.json
remote_src: true
status_code: [200, 201, 204]
no_log: true

Per-parameter censoring has a blind spot of its own, and it is an ugly one. Ansible prints the loop item as a label on every result line, and that label is built on the control node from your own data, long before any module argument spec is consulted.

tasks/accounts.yml
- name: Create service accounts
ansible.builtin.user:
name: "{{ item.name }}"
password: "{{ item.hash }}"
shell: /usr/sbin/nologin
loop: "{{ service_accounts }}"
output
TASK [Create service accounts] **************************************************
changed: [web1.example.com] => (item={'name': 'app-runner', 'hash': '$6$rounds=656000$xK9pQ2Lm$7VdQhN0sK2mE5rL9pXcT1uJ6yA3fW8bG4nH7dS2kR5vC1zM9qP0'})
changed: [web1.example.com] => (item={'name': 'log-shipper', 'hash': '$6$rounds=656000$Zq4wRtBa$9NcH3jL7gN1sD5hK9mV0uY8xB2aF4jL7gN1sD5hK9mV0uY8xB2a'})

That is verbosity zero. Default settings, no flags, and the password hashes are in the log. ansible.builtin.user marks password as sensitive perfectly correctly, and it makes no difference whatsoever, because the label never went near the argument spec. Put no_log: true on the task and each label is replaced with (censored due to no_log). Or keep the loop readable by iterating over account names and resolving each hash separately, which is the shape you wanted anyway. Setting a label under loop_control also works and is the weaker choice, because it only helps on the days you remember that the default label is the entire item.

When no_log and ignore_errors Meet at 2am

ignore_errors: true tells Ansible to carry on when a task fails. Paired with no_log, a failure looks like this, and it looks like this at every verbosity level, because failed results are always dumped.

output
TASK [Rotate the service account credential] ***********************************
fatal: [web1.example.com]: FAILED! => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}
...ignoring

Something broke, the play carried on, and you have nothing to work with. The censoring cannot be smarter than this, because Ansible has no way to tell an error message apart from a payload. So at 2am one of two things happens. You delete the no_log line and rerun in the pipeline, which publishes the entire result into a job log whose retention policy you did not choose. Or you add a debug: var: rotate after it, which prints the uncensored registered variable, which is worse. Both edits are one line, both feel temporary, and both sail through review because the diff reads as a debugging change.

The move that costs you nothing is to reproduce the failure somewhere disposable: one scratch host, a throwaway credential you are happy to burn, and no_log removed for that run only. In the playbook itself, leave future-you something usable by deriving a safe status line instead of dumping the result.

rotate.yml
- name: Rotate the service account credential
ansible.builtin.uri:
url: "https://vault.example.com/v1/rotate/{{ svc_account }}"
method: POST
headers:
X-Vault-Token: "{{ vault_token }}"
register: rotate
no_log: true
ignore_errors: true
- name: Explain the outcome without the payload
ansible.builtin.debug:
msg: >-
rotate account={{ svc_account }}
http={{ rotate.status | default('none') }}
failed={{ rotate.failed | default(false) }}
reason={{ rotate.msg | default('n/a') | truncate(80) }}
output
TASK [Explain the outcome without the payload] *********************************
ok: [web1.example.com] => {
"msg": "rotate account=app-runner http=403 failed=True reason=Status code was 403 and not [200]: HTTP Error 403: Forbidden"
}

That works because the registered variable kept everything, which is the same behaviour that bit you two sections ago. A 403 tells you this is a permissions problem and not a network fault, which is most of what you needed at 2am. Check msg per module before you trust it, though. It is safe for uri because it describes the HTTP (HyperText Transfer Protocol, the request-and-response rules the web runs on) status, but some modules put the failing command or the full URL into msg, and then you have rebuilt the leak inside your own diagnostic.

The Blunt Instruments, and One Pointing the Wrong Way

ANSIBLE_NO_LOG=True in the environment (or no_log = True under [defaults] in ansible.cfg) applies no_log to every task in the run. It is a genuine setting and it does precisely what it claims, which is why it is close to useless as a daily default: the run turns into a wall of identical stubs and nobody can tell a clean deploy from a broken one. It earns its keep for a one-off run you know will touch credentials in twenty places, or as a temporary switch while you work out which task is doing the leaking.

terminal
ANSIBLE_NO_LOG=True ansible-playbook -i inventory/production rotate-all.yml -v
output
TASK [Gathering Facts] *********************************************************
ok: [web1.example.com] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}
TASK [Read the current credential] *********************************************
ok: [web1.example.com] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}
TASK [Write the new credential] ************************************************
changed: [web1.example.com] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": true}
PLAY RECAP *********************************************************************
web1.example.com : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

One switch points the other way. ANSIBLE_KEEP_REMOTE_FILES=1 (config key keep_remote_files under [defaults]) is a debugging aid that tells Ansible to leave the generated module payload on the managed host instead of deleting it. That payload carries the module's arguments in clear text, in a directory under the remote user's home, and no_log does not touch it because it was never output in the first place. Turning it on also switches pipelining off, which is one way to catch it in review. If you set it to chase a problem, unset it, then go and look under ~/.ansible/tmp/ on the targets for what you left there. Fact caching has the same shape of problem: a set_fact with cacheable: true writes the value into the fact cache as plain text, and if fact_caching is set to jsonfile or redis rather than the default memory, that value outlives the run that created it.

Prove the Control Works With a Canary

A control you have never tested is a belief. This one is easy to test, because you can substitute a fake secret and then go hunting for it. Pick a value that exists nowhere else on earth, feed it in as an extra variable (which has the highest precedence of any variable and overrides your real vaulted value), run against one staging host at maximum verbosity with the diff switched on, and grep.

terminal
CANARY_VARS=$(mktemp)
printf 'deploy_api_token: CANARY-7f3a9b21\n' > "$CANARY_VARS"
ansible-playbook -i inventory/staging register.yml \
--limit stage-web1.example.com --diff -vvv \
-e "@$CANARY_VARS" > /tmp/canary-run.log 2>&1
grep -c 'CANARY-7f3a9b21' /tmp/canary-run.log
grep -rl 'CANARY-7f3a9b21' /var/log/ansible/ ~/.ansible/ 2>/dev/null
rm -f "$CANARY_VARS"
output
0

Zero is a pass, and an empty second line means nothing reached the run log or the fact cache. Anything else is a leak that arrives with a file name attached. One catch, and it is the catch that makes most homegrown checks worthless: a green result also happens when the task never ran, because --limit matched nothing or a when skipped it. Run the whole thing once with the no_log taken off and confirm the canary does show up. A test that cannot fail is telling you nothing. Then repeat the search in the places you do not control directly, which is the pipeline job log for that run and the AWX job output view, because those are the copies that outlive you.

Then go and look at the file you actually wrote on the target, because that one holds a real secret in a real place and needs the right owner and mode rather than no_log.

terminal
ansible -i inventory/staging stage-web1.example.com -b \
-m ansible.builtin.stat -a "path=/etc/app/app.ini" \
| grep -E '"(mode|pw_name|gr_name)"'
output
"gr_name": "app",
"mode": "0640",
"pw_name": "app",
Quick check
01A task carries no_log: true and registers its result. What does no_log actually stop?
Incorrect — the value still travels to the target as a module argument, because no_log governs what gets recorded, not what gets sent.
Incorrect — register stores the full uncensored result; only the copy handed to the callbacks is replaced.
Correct — the swap happens before any callback runs, so terminal, log file, AWX events and CI log all get the stub, and the module also skips its Invoked with line.
Incorrect — nothing is encrypted here; the dictionary is discarded and substituted, not scrambled.
02You move a database password out of a shell: command line and into environment: on the same task. What happens to the exposure in the managed host's process table?
Incorrect — on Linux there is no such channel; Ansible builds the env assignment into the command string it runs.
Correct — Ansible prepends VAR='value' to the remote command, so ps still shows it, and -vvv prints the same string in the SSH: EXEC line.
Incorrect — that is true of environ itself, but the value reaches the target as part of a command line, so ps catches it before it ever becomes an environment variable.
Incorrect — it does reach the target and does set the variable; the problem is the route it takes to get there.
03A run shows: fatal: [web1]: FAILED! => {"censored": "the output has been hidden..."} followed by ...ignoring. You need to know why it failed. What is the safe move?
Incorrect — that publishes the complete result, secret included, into a job log retained for months by default.
Incorrect — the registered variable holds the uncensored result, so this prints the payload in full.
Incorrect — no_log still censors that result at -vvv, so you learn nothing new while every other task starts printing more than it did.
Correct — you get the full error, and the only value you expose is one you were already prepared to burn.

The habit that keeps you out of trouble is not a setting. Before you write register: on a task, decide who is allowed to read that dictionary six months from now. If the honest answer is that you do not know who has access to the pipeline log archive, the task gets no_log: true and a separate, boring status line for the humans. Write that boring line at the same moment as the no_log, not after the first failure wakes you up. It is the difference between an incident and a Tuesday.

Related