Every config-management deployment has a tension nobody talks about: managed files take away the engineer's ability to customize. The first time someone runs ansible-playbook bash-env.yml and watches their carefully-tuned ~/.bashrc get replaced with the company-standard version — with their personal aliases gone — they will fight the system. Either by disabling Ansible runs, or by maintaining a side document with "stuff I need to re-add after every deploy."
The fix is a single convention: a managed system sources an unmanaged sibling file.
# In the managed ~/.bashrc — the last line:
[ -f "$HOME/.bashrc.local" ] && . "$HOME/.bashrc.local"
That's the entire contract. Anything in ~/.bashrc.local is the engineer's. Ansible never touches it. The managed bashrc just sources it after everything else, so per-user customizations win.
What goes in .bashrc.local
By convention:
- Personal aliases (
alias mywork='cd ~/projects/widget && code .') - Personal functions (a one-off helper that's not worth promoting to the fleet)
- Machine-specific env vars (
export GRADLE_OPTS=...) - Per-user secret loaders (
source ~/.work-creds.sh) - Overrides of fleet defaults (e.g., a different
PS1for the engineer who hates colors)
What does NOT go in .bashrc.local
- Shared customizations. If three engineers all add the same alias to
.bashrc.local, that's a signal the alias belongs in the fleet file. Promote it. - Secrets you should rotate. The whole world is now manually copy-pasting
.bashrc.localbetween machines. That's an exfiltration vector. Use~/.mysql_env.sh,~/.work-creds.sh, or similar 0600 files instead, and source those from.bashrc.local.
The Ansible side — create the stub, never overwrite
- name: Create per-user .bashrc.local stub (untouched on rerun)
copy:
dest: "{{ user_home }}/.bashrc.local"
content: |
# ~/.bashrc.local — your personal overrides.
# This file is never overwritten by Ansible. Drop personal aliases,
# env vars, or function defs here. Sourced last, so your settings
# win over the fleet defaults.
owner: "{{ user }}"
group: "{{ user }}"
mode: '0600'
force: no # critical — do NOT overwrite if it exists
The force: no is the entire design pattern in one keyword. On first run, the stub is created. On every subsequent run, Ansible sees the file exists and leaves it alone — even if its contents have drifted wildly from the stub.
Why the discipline matters beyond bash
This pattern — managed file references an unmanaged sibling — works for almost any config-as-code scenario where you also want to permit per-host or per-user variation:
- nginx:
include /etc/nginx/conf.d/*.local.conf;at the end of a managed main config. - systemd units: the
override.confdrop-in directory pattern lets you tweak unit files without forking them. - SSH client:
~/.ssh/config.d/as an include directory. - Git:
~/.gitconfig.localsourced from a managed~/.gitconfig.
In every case, the managed system retains control of structure and defaults; the local file gets the last word on overrides. Engineers stop fighting the system because the system respects them.
Closing the series
Seven articles. The single thread running through all of them: ~/.bashrc is not a personal scratch file. It's the surface where your operating environment, your safety controls, your secrets policy, and your team's process all meet. Treat it that way and the leverage compounds across every shell you start for the rest of your career.
The hidden tiger has teeth. I hope this series gave you a few of them.
A managed file that respects its unmanaged sibling is the difference between config-as-code and config-as-tyranny.
Back to the series index: .bashrc. Or browse other writing: Engineering · Field Notes.