Encryption #

Encryption is the last line of defense in an Ansible security architecture. It doesn’t replace access control or auditing, but it ensures that even when other controls fail — a stolen laptop, a leaked backup disk, logs exposed to third parties — the stolen data still can’t be read without the right key. Ansible touches encryption at many points: variables containing passwords are encrypted with Vault, transport connections to managed nodes are secured with SSH/TLS, SSL/TLS certificates for internal services must be distributed and rotated, and sensitive data in logs must be redacted. This article discusses each of these points systematically — when to use symmetric versus asymmetric algorithms, how to manage key lifecycles, and common safe patterns for each scenario. A good understanding of each algorithm’s strengths and weaknesses prevents choices that look secure but are actually fragile.

When Is Encryption Needed? #

SENSITIVE DATA:
  ✓ Passwords and API keys → ENCRYPT REQUIRED (Vault)
  ✓ Private keys (SSH, TLS) → ENCRYPT REQUIRED (file permissions + Vault)
  ✓ Database connection strings with credentials → ENCRYPT REQUIRED
  ✓ Production configuration files (web server, app config) → ENCRYPT REQUIRED
  ✓ Backup files containing data → ENCRYPT REQUIRED (LUKS/dm-crypt)
  ✓ Session tokens for users → ENCRYPT in transit (TLS), EXPIRE quickly

NON-SENSITIVE DATA:
  ✗ Hostnames and server IPs → not needed
  ✗ Directory paths (/var/log/app) → not needed
  ✗ Regular usernames → not needed
  ✗ Non-secret configuration (ports, log levels) → not needed

Encryption adds CPU overhead and operational complexity. Not all data needs encrypting — only what has material risk if leaked. Classifying sensitive data is the first step before determining the right encryption strategy.


Encryption Architecture in Ansible #

flowchart LR
    A["Ansible<br/>Control Node"] -->|"SSH/TLS"| B["Managed Node"]
    A -->|"Vault encrypt"| C["Vault File<br/>AES256"]
    A -->|"TLS"| D["Secret Manager<br/>Vault/AWS SM"]
    B -->|"SSH key"| E["authorized_keys"]
    B -->|"TLS cert"| F["Service<br/>nginx/postgres"]
    D -->|"runtime inject"| A
    C -->|"decrypt at runtime"| A
    A -->|"AnsibleModule"| B

Encryption happens at four points in the Ansible ecosystem: Vault files on the control node (encrypted at commit, decrypted at runtime), transport connections (SSH between control and managed nodes, or HTTPS to APIs), certificates on managed nodes (TLS for internal services), and external secret managers (Vault, AWS Secrets Manager) that provide secrets to playbooks at runtime without ever storing plaintext on Ansible disk.


Symmetric vs Asymmetric Algorithms #

flowchart TD
    A["Choose Algorithm"] --> B{"Data type?"}
    B -->|"Large files/data<br/>encrypted once<br/>and read many times"| C["Symmetric<br/>AES-256-GCM"]
    B -->|"Keys need<br/>sharing with<br/>many parties"| D["Asymmetric<br/>RSA/Ed25519/ECDSA"]
    C --> E["Vault file<br/>LUKS disk<br/>TLS bulk encryption"]
    D --> F["SSH key pair<br/>TLS handshake<br/>Digital signature"]

The choice between symmetric and asymmetric algorithms determines the entire encryption operation. Symmetric (AES-256-GCM) suits large amounts of data encrypted once and read many times — Vault files, full disk encryption, or bulk data encryption. Asymmetric (RSA, Ed25519, ECDSA) suits situations where public keys must be shared with many parties without sacrificing the private key — TLS handshakes, SSH key pairs, and digital signatures. Almost all modern systems use both in a hybrid fashion: TLS for example uses RSA/ECDSA for session key exchange, then AES for bulk encryption.


Algorithm Comparison Table #

AlgorithmTypeKey lengthUse caseNotes
AES-256-GCMSymmetric256 bitVault files, disk encryption, TLS bulkIndustry standard, hardware acceleration on modern CPUs
ChaCha20-Poly1305Symmetric256 bitEnvironments without hardware AES accelerationFast in software, popular on mobile
RSA-2048Asymmetric2048 bitLegacy TLS, signaturesOutdated, migration to ECDSA recommended
RSA-4096Asymmetric4096 bitHigh-security signaturesSlow for handshakes, use only if truly needed
ECDSA P-256Asymmetric256 bitTLS 1.3, SSH, modernFast, small signature size
Ed25519Asymmetric256 bit (key)SSH (preferred), signaturesFastest, 64-byte signatures
bcryptHash + saltvariablePassword hashingMinimum cost factor 12 in 2024
Argon2idHash + memory-hardvariableNewer password hashingStronger than bcrypt against GPU attacks
scryptHash + memory-hardvariablePassword hashingArgon2id alternative

For Ansible, AES-256 via Vault is the default choice. For SSH keys, Ed25519 provides the best security and performance. For passwords, use bcrypt with cost factor ≥ 12 or Argon2id if the library is available.


Ansible Vault — At-Rest Encryption #

Vault encrypts YAML files with AES-256. Vault files can still be included in playbooks as usual, but their contents can’t be read without the vault password. Ansible recognizes Vault files automatically based on the $ANSIBLE_VAULT;1.1;AES256 header.

# Create a new Vault file
ansible-vault create secrets/production.yml

# Edit a Vault file (prompts for password)
ansible-vault edit secrets/production.yml

# Encrypt an existing YAML file
ansible-vault encrypt existing-file.yml

# View contents without editing
ansible-vault view secrets/production.yml

# Decrypt for operations needing plaintext (rare)
ansible-vault decrypt secrets/production.yml

Every operation requires the vault password. The password can be entered interactively (--ask-vault-pass), from a file (--vault-password-file), from an environment variable (ANSIBLE_VAULT_PASSWORD_FILE), or from a custom script (--vault-password-client) that fetches the password from an external secret manager at runtime.

# ANTI-PATTERN: vault password in Git
# Wrong .gitignore: forgot to ignore the vault password file
# Consequence: vault_password_prod.txt gets committed, attacker can decrypt all vaults
# $ cat /var/log/git-history/vault_password_prod.txt
# my-s3cr3t-vault-password

# CORRECT: vault password from a secret manager, injected at runtime
# playbooks/deploy.yml
- name: Deploy with the vault password from AWS Secrets Manager
  hosts: production
  vars:
    vault_password: "{{ lookup('aws_secret', 'ansible/vault-password', region='ap-southeast-1') }}"
  vars_files:
    - secrets/production.yml  # Vault file, decrypted at runtime
  tasks:
    - name: Deploy the application
      template:
        src: app.conf.j2
        dest: /etc/myapp/app.conf
        mode: '0640'
        owner: root
        group: myapp

Injecting the vault password at runtime from an external secret manager ensures the password is never stored in Git, in Ansible configuration files, or in persistent environment variables.


Multi-Vault Strategy #

One vault password for all environments is very inflexible. Each environment should have its own vault, with a different password, so a leak in one vault doesn’t expose other environments.

# group_vars/all/vaults.yml
# Vault definitions per environment — included in vars_files
vault_files:
  development: secrets/dev.yml
  staging: secrets/staging.yml
  production: secrets/production.yml

# Vault passwords also per environment
vault_passwords:
  development: "{{ lookup('env', 'ANSIBLE_VAULT_DEV_PASSWORD') }}"
  staging: "{{ lookup('env', 'ANSIBLE_VAULT_STAGING_PASSWORD') }}"
  production: "{{ lookup('aws_secret', 'ansible/vault-password', region='ap-southeast-1') }}"
# ANTI-PATTERN: one vault for all environments
# secrets/all.yml — contains production AND dev AND staging passwords
# vault_password: 'one-password-for-everyone'
# A leak = compromise of the entire organization

# CORRECT: vault per environment + audit
# secrets/production.yml
---
production_db_password: "strong-random-32-char-password"
production_api_key: "very-long-random-api-key"
production_tls_cert: |
  -----BEGIN CERTIFICATE-----
  ...
  -----END CERTIFICATE-----  

Separating vaults per environment enables separate auditing — the production vault is only accessed from trusted CI/CD, while the development vault can be accessed from developer workstations without sacrificing production security.


Rotate passwords periodically. Vault passwords, database passwords, and API keys must be rotated at least every 90 days. Ansible can automate rotation with a playbook that generates a new password, updates the Vault, and restarts services depending on that secret. Without rotation, a leaked password stays valid until the leak is detected — which could take years.

Transport Encryption with SSH #

The connection between the Ansible control node and managed nodes is always encrypted by SSH. But weak SSH configuration (old protocols, weak ciphers, weak key exchange) reduces the effectiveness of transport encryption.

# roles/ssh-hardening/tasks/main.yml
---
- name: Deploy a secure sshd_config
  template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config
    owner: root
    group: root
    mode: '0600'
  notify: Restart sshd

- name: Disable old protocols and ciphers
  lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "{{ item.regexp }}"
    line: "{{ item.line }}"
    state: present
  loop:
    - regexp: '^#?Protocol'
      line: 'Protocol 2'
    - regexp: '^#?Ciphers'
      line: 'Ciphers [email protected],[email protected],[email protected]'
    - regexp: '^#?MACs'
      line: 'MACs [email protected],[email protected]'
    - regexp: '^#?KexAlgorithms'
      line: 'KexAlgorithms [email protected],diffie-hellman-group-exchange-sha256'

The combination of chacha20-poly1305 + curve25519-sha256 + hmac-sha2-512-etm provides strong transport security without sacrificing performance, even on CPUs without hardware AES acceleration.


SSH Key Distribution #

SSH key pairs provide more secure authentication than passwords. Distributing public keys to managed nodes should be automated, but private keys must stay secure.

# Generate an SSH key pair on the control node
ssh-keygen -t ed25519 -C "ansible-control-prod" -f ~/.ssh/ansible_ed25519

# Public key distributed to managed nodes via Ansible
ansible all -m authorized_key -a "
  user=ansible
  key='{{ lookup('file', '~/.ssh/ansible_ed25519.pub') }}'
  state=present
"
# ANTI-PATTERN: SSH key without passphrase, manual copy to every server
# ssh-keygen -t rsa -b 2048 -N "" -f deploy_key
# scp deploy_key.pub user@server1:~/.ssh/authorized_keys
# scp deploy_key.pub user@server2:~/.ssh/authorized_keys
# ... (thousands of servers)
# scp deploy_key user@laptop:/tmp/  # leaks to a new laptop

# CORRECT: passphrase + Ansible push + periodic rotation
# 1. Generate a key with a passphrase, store the passphrase in Vault
# 2. Ansible pushes the public key to all managed nodes
# 3. ssh-agent on the control node holds the decrypted key at runtime
# 4. Rotate the keypair every 6-12 months
- name: Distribute the new SSH public key to managed nodes
  hosts: all
  tasks:
    - name: Add the ansible public key to authorized_keys
      authorized_key:
        user: ansible
        key: "{{ lookup('file', '~/.ssh/ansible_ed25519.pub') }}"
        state: present
        exclusive: true  # Remove other keys, ensure only the new key is valid
      notify: Audit SSH key change

An SSH key without a passphrase on a developer laptop is a security nightmare — once the laptop is stolen, the attacker has access to the entire infrastructure. Passphrase + ssh-agent provides security without sacrificing convenience.


SSL/TLS Certificates #

Internal services (nginx, PostgreSQL, etc.) often use TLS for encrypted communication. Certificates must be generated, distributed, and rotated periodically.

# roles/tls-cert/tasks/main.yml
---
- name: Generate the private key
  community.crypto.openssl_privatekey:
    path: /etc/ssl/private/{{ service_name }}.key
    size: 4096
    type: RSA
    mode: '0600'
    owner: root
    group: ssl-cert
  register: private_key

- name: Generate the CSR (Certificate Signing Request)
  community.crypto.openssl_csr:
    path: /etc/ssl/csr/{{ service_name }}.csr
    privatekey_path: /etc/ssl/private/{{ service_name }}.key
    common_name: "{{ service_name }}.internal.example.com"
    subject_alt_name:
      - "DNS:{{ service_name }}.internal.example.com"
      - "DNS:{{ service_name }}"
    organization_name: "Example Corp"
    organizational_unit_name: "Infrastructure"
    country_name: "ID"
    key_usage:
      - digitalSignature
      - keyEncipherment
    extended_key_usage:
      - serverAuth
      - clientAuth
  when: private_key.changed

- name: Self-sign the certificate (for an internal CA)
  community.crypto.x509_certificate:
    path: /etc/ssl/certs/{{ service_name }}.crt
    privatekey_path: /etc/ssl/private/{{ service_name }}.key
    csr_path: /etc/ssl/csr/{{ service_name }}.csr
    provider: selfsigned
    selfsigned_not_after: "+365d"  # Valid for 1 year
    mode: '0644'
    owner: root
    group: root
  when: private_key.changed

For internal services, self-signed certificates from an internal private CA are sufficient. For services accessed from the internet, use Let’s Encrypt or another external CA with the ACME protocol for auto-renewal.


Self-signed certificates need explicit trust. Browsers and other TLS clients reject self-signed certificates from unknown CAs. For internal infrastructure, deploy the CA certificate to all managed nodes (via Ansible, of course) and add it to the system trust store. Without this step, internal TLS services will always fail the handshake with a “certificate verify failed” error.

Backup Encryption #

Backups are often the weak point in an encryption strategy — created from a live database (plaintext), stored on disk or cloud storage without encryption, and carrying sensitive data to a wider attack surface. Backups must be encrypted before leaving the originating host.

# Backup PostgreSQL + encrypt with GPG before upload
pg_dump -Fc production_db > /tmp/production_db.dump
gpg --symmetric --cipher-algo AES256 \
    --output /backup/production_db.dump.gpg \
    /tmp/production_db.dump
rm -f /tmp/production_db.dump  # Remove the plaintext
aws s3 cp /backup/production_db.dump.gpg \
    s3://company-backups/production/$(date +%Y%m%d)/
shred -u /backup/production_db.dump.gpg  # Remove locally after upload

Or use LUKS/dm-crypt for full disk encryption on backup volumes — the entire volume is encrypted at the block device level, transparent to applications.


Redacting Sensitive Data in Logs #

Logs that display passwords, API keys, or private keys in plaintext are a delayed data leak — usually undetected until an audit or until the logs are exposed to third parties.

# ANTI-PATTERN: logging output that displays rendered templates
# ansible-playbook site.yml -vvv
# TASK [Deploy config] ****
# ok: [server1] => {
#     "msg": "Rendered template content: db_password=MyS3cretP@ss"
# }
# This log now exists in syslog, journal, and possibly CI artifacts

# CORRECT: no_log: true for tasks that render secrets
- name: Deploy the database configuration
  template:
    src: db.conf.j2
    dest: /etc/myapp/db.conf
    mode: '0640'
  no_log: true  # Don't log this task's output

- name: Test the database connection
  command: psql -f /etc/myapp/db.conf -c "SELECT 1"
  no_log: true  # Query output might leak credentials
  changed_when: false

no_log: true is one line that prevents many incidents. Task output won’t appear in Ansible logs, syslog, or CI artifacts. For CI/CD that stores log artifacts, without no_log: true you’re effectively writing passwords to public storage.


Decision Tree — Choosing the Encryption Approach #

flowchart TD
    A["What data<br/>is being encrypted?"] --> B{"Category"}
    B -->|"Secret variables<br/>passwords, API keys"| C["Vault file<br/>AES-256"]
    B -->|"Transport<br/>SSH/TLS"| D["SSH key pair<br/>or TLS cert"]
    B -->|"Disk/storage"| E["LUKS/dm-crypt"]
    B -->|"Backup files"| F["gpg symmetric<br/>or LUKS"]
    B -->|"User passwords"| G["bcrypt cost 12+<br/>or Argon2id"]
    C --> H{"Environment?"}
    H -->|"Production"| I["Separate Vault<br/>+ AWS SM inject"]
    H -->|"Dev/staging"| J["Separate Vault<br/>+ env var password"]
    D --> K{"Internal or<br/>public-facing?"}
    K -->|"Internal"| L["Self-signed cert<br/>+ private CA"]
    K -->|"Public"| M["Let's Encrypt<br/>+ ACME renewal"]

This decision tree helps choose the right encryption approach for various data types. The key is classifying the data first — then choosing the algorithm.


Summary #

  • AES-256 via Vault is the default for at-rest data in Ansible. Use a separate Vault per environment, and inject the vault password from an external secret manager at runtime.
  • Ed25519 is the best choice for SSH keys — fast, small, and secure. Always use a passphrase and keep the decrypted key in ssh-agent, never in a passphrase-less file.
  • Internal TLS can use self-signed certificates from a private CA — deploy the CA certificate to all managed nodes via Ansible for trust.
  • Backups must be encrypted before leaving the host — GPG symmetric for individual files, LUKS for full disk encryption on backup volumes.
  • no_log: true is a mandatory line for tasks that render templates containing credentials or whose output might leak secrets.
  • bcrypt with cost factor ≥ 12 or Argon2id for password hashing. Never store plaintext passwords or hashes with SHA1/MD5.
  • Periodic rotation — Vault passwords, API keys, and SSH keys must be rotated at least every 90 days. Ansible can automate the entire rotation process.

← Previous: Ansible Vault Next: Workflow →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact