Skip to content

Lesson 6 of 9 · 24m

Jinja2 Templates and Vault Secrets

template vs copy vs lineinfile, the Jinja2 idioms that show up in every config file, and how vault encryption keeps secrets in git without leaking them.

Someone pasted a Slack export into a retro doc once: "prod was down because the deploy script wrote the staging DB password into the prod config." The script was a sed one-liner. Templates exist so that class of bug — wrong file, wrong values, wrong environment — becomes structurally impossible, and vault exists so the secrets in those templates can live next to the code without leaking.

template: render Jinja2 into place

- name: Ship app config
  ansible.builtin.template:
    src: app.env.j2          # resolves from templates/ or the role's templates/
    dest: /opt/acme/app.env
    owner: acme
    group: acme
    mode: "0600"
    validate: /usr/sbin/app --check-config %s   # test the rendered file before installing
  notify: Restart acme-app

The validate: arg is criminally underused — sshd -t, nginx -t, visudo -cf all work the same way: render to temp, validate, install only on pass. A bad config never lands.

{# templates/app.env.j2 #}
# Managed by Ansible — manual edits will be overwritten
DATABASE_URL=postgres://{{ db_user }}:{{ db_password }}@{{ db_host }}:5432/{{ db_name }}
LOG_LEVEL={{ log_level | default('info') }}
ALLOWED_HOSTS={{ allowed_hosts | join(',') }}
{% for host in groups['metrics'] %}
METRICS_PEER_{{ loop.index }}={{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}
{% endfor %}

The idioms worth memorizing: | default('x') for optional knobs, | join(',') for lists, {% for %} + loop.index for peer enumeration, groups[]/hostvars[] for cross-host values. {# comments #} don't render; // and # inside the file do.

copy vs lineinfile vs template — pick by ownership

  • template: — you own the whole file. Rendered from Jinja, drift-proof.
  • copy: content: — you own the whole file, it's short or static.
  • lineinfile: / blockinfile: — you own one line or block inside a file other tools also touch (/etc/fstab, sudoers fragments). It manages marked regions; it's not a merge.

Using lineinfile to patch a file you actually own is a smell — just template it and put the "managed by Ansible" header on it.

vault: secrets that can live in git

ansible-vault encrypt group_vars/dbservers/vault.yml   # encrypt a whole file
ansible-vault encrypt_string 's3cr3t' --name db_password   # one var, inline
ansible-vault edit group_vars/dbservers/vault.yml      # decrypt → edit → re-encrypt
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt

The file pattern that survives team growth: every group_vars/<group>/ gets a vars.yml (plaintext) and a vault.yml (encrypted). Plaintext vars reference vaulted vars — db_password: "{{ vault_db_password }}" — so the name is greppable but the value never is. Vault encrypts at rest; once decrypted in memory it's indistinguishable from any other variable.

Field notes

  • no_log: true on tasks that handle secrets — otherwise the decrypted value prints in -v output and lands in CI logs.
  • Vault IDs (--vault-id prod@~/.vault_prod) let one repo hold per-environment passwords; Ansible prompts/loads the right one.
  • ansible-vault rekey changes the vault password; rotating a secret value is edit. Never commit the password file — .vault_pass.txt belongs in .gitignore on day one.