Encrypt Ansible variables with SOPS

encrypted-ansible-variables

SOPS (Secret OPerationS) is a useful tool for encrypting sensitive data, but it doesn’t get enough praise. It supports full file encryption, and value encryption in structured data formats such as YAML, JSON, ENV, and INI. You can choose different encryption methods, such as age, PGP, Vault, and KMS (AWS, GCP, and Azure).

The super easy-to-use value-only encryption is where SOPS overshadows Ansible Vault - Ansible’s default tool for the job. With Ansible Vault, you can easily encrypt and decrypt the variable files, but if you want to keep variable names unencrypted, things get a little complicated because you have to encrypt every variable individually. This is not a big deal if you have just a few sensitive variables, but it’s a real pain in the butt if you have a large number of secrets. Things get even more complicated when you want to rotate your keys and re-encrypt your variables.

When you edit variables YAML file with SOPS, the variable names are automatically left unencrypted. If you want to achieve similar result with Ansible Vault, only in a slightly less painful way, you can create two sets of variables. The ones that are stored in plain text, and only reference the encrypted ones. For example:

1
2
3
---
my_role_username: foo
my_role_password: "{{ my_role_encrypted_password }}"

Obviously, the my_role_encrypted_password variable should be stored in a separate, fully-encrypted file.

Why encrypt only variable values, you’re wondering? It’s a convenient way of having the ability to see which hosts or groups use/override certain variables, without revealing the sensitive data. This is very useful if want to track variable changes in Git history or if you want to find out where’s certain variable overridden with recursive grep search.

Prerequisites

To use SOPS with Ansible, you need SOPS executable and SOPS community collection. To keep things as simple as possible, we’ll use age for secret encryption.

Setup

First, we’ll need a pair of age keys that we’ll use later during the encryption process. You can generate a new key pair using age-keygen command. Make sure to output your key to a secure location.

age-keygen -o /my/secure/location/age.txt

If you inspect the file, you’ll notice that it contains two keys. Private one, with AGE-SECRET-KEY- prefix that should be kept secret at all times, and a public key which can be shared with anyone.

SOPS requires access to the private key when encrypting and decrypting files. You can let SOPS know what’s your private key by storing it to ~/.config/sops/age/keys.txt (the default location where SOPS looks up age keys on Linux systems) or via one of the environment variables:

  • SOPS_AGE_KEY_FILE - contains the path to your private key.
  • SOPS_AGE_KEY - contains the value of your private key.

With that out of the way, let’s look at a very simple Ansible playbook project hierarchy that we’ll use in this example.

.
├── ansible.cfg                 # Ansible configuration
├── inventory
│   ├── host_vars
│   │   ├── localhost.sops.yaml # encrypted variables
│   │   └── localhost.yaml      # plain text variables
│   └── my_inventory.yaml
├── roles
│   └── sops_test
│       ├── defaults
│       │   └── main.yml
│       └── tasks
│           └── main.yml
└── sops_test.yaml

The ansible.cfg contains plugins required for automagic SOPS variable decryption:

1
2
[defaults]
vars_plugins_enabled = host_group_vars,community.sops.sops

The sops_test role is very simple. It has two variables, one of which should be kept secret:

1
2
3
4
# roles/sops_test/defaults/main.yml
---
sops_test_example: default_example_value
sops_test_password: default_password  # When overriding this one, it should be kept secret

Role’s tasks only print the variable values:

1
2
3
4
5
6
7
8
9
# roles/sops_test/tasks/main.yml
---
- name: Print regular variable
  ansible.builtin.debug:
    msg: "sops_test_example value: {{ sops_test_example }}"

- name: Print encrypted value
  ansible.builtin.debug:
    msg: "sops_test_password value: {{ sops_test_password }}"

The inventory contains only localhost for this example:

1
2
3
4
5
# inventory/my_inventory.yaml
---
base:
  hosts:
    localhost:

The playbook simply calls the sops_test role:

1
2
3
4
5
6
# sops_test.yaml
---
- hosts: localhost
  gather_facts: no
  roles:
    - role: sops_test

As noted in the community.sops.sops vars plugin documentation, SOPS will decrypt variables from group_vars and host_vars directories. By default, the plugin will only consider files with .sops.yaml, .sops.yml, and .sops.json extensions, unless configured differently with the valid_extensions parameter.

Encrypting Ansible variables

Add .sops.yml to the root of your project, and create a creation rule that instructs SOPS which public key should be used during the encryption process of new files (i.e. who will be the so called recipient). E.g. the following creation rule states to use a single age public key the for encryption of all new files:

1
2
3
---
creation_rules:
  - age: age1egcdre0jxe96vjjydaxx8xjgtc5pr76jtyd0fptwplmmjk6jyf5q2punfc

You can configure additional parameters, if you want to use different encryption settings on different files.

Now, let’s encrypt the secret in our example. Export the SOPS_AGE_KEY or SOPS_AGE_KEY_FILE environment variable and create the inventory/host_vars/localhost.sops.yaml file by calling sops command. SOPS will open the file using your $EDITOR.

export SOPS_AGE_KEY_FILE=/path/to/your/age-key.txt
sops inventory/host_vars/localhost.sops.yaml

While editing, you’ll see the unencrypted content. E.g.:

1
2
---
sops_test_password: this_will_be_encrypted

But once you save the file and close the editor, the file contents will look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
sops_test_password: ENC[AES256_GCM,data:H/hKwQtT/kZbsxfjeVwywHLTWCwtWQ==,iv:oT8Z2/8cO39QLYijqqYyN9Dk8MbdlXVWWjQC8rlduYs=,tag:voGDQXEO3MJ9a28LF//yTw==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    hc_vault: []
    age:
        - recipient: age1egcdre0jxe96vjjydaxx8xjgtc5pr76jtyd0fptwplmmjk6jyf5q2punfc
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBraUs3UTJLVFZiNDZCMDBl
            Vm91UjlOVUpDK3VUbzlpa2tZUjhEODVzWkRFCllyTjZjQTZoVTRVRUk0UWMwNFNy
            QU9BRVdTNDRDSDVkTUNKOEtPYTU3elkKLS0tIEl0c3MvRUdaRTBrVklTV3gvWjU5
            R0JMVWNGNCtCUk9LazlwdDVDLzVYOTAK7Qmkt8//AjsVUn2CvjHSe0Pl9FeODPht
            baCGSZX0g/mXSBU4WujUz3xK0T89cFF/4yVXOAMMbeqwq5p+E5k0jw==
            -----END AGE ENCRYPTED FILE-----            
    lastmodified: "2024-08-25T15:26:26Z"
    mac: ENC[AES256_GCM,data:8tkzOGTJ4P15H88kNgep8sHo4vCv4oz5j4Ol+LehRV6WG+UC9DH0/baqNMqK7SPFKgZNh+QmVOiucvKBGpIk2TBXaRrJhtgXvOB2JI8S165XHMhpbsn6/lP0nhXEtekw8agAF34es6M4xqsaf4G3wDphPo7s5MUr1jk+ueiMn1E=,iv:BC4QirERRzea5QjRuY/QBEJuTzaXJjMiin0RPLTn1+s=,tag:1Kdx9HuVeqIUJxQ2Yjy1aQ==,type:str]
    pgp: []
    unencrypted_suffix: _unencrypted
    version: 3.8.1

Essentially, there’s the sops_test_password variable with encrypted value, and some additional SOPS metadata.

Since sops_test_example isn’t a secret, we can store it in plain text format:

1
2
# inventory/host_vars/localhost.yaml
sops_test_example: I got overridden!

If you run the playbook, you’ll get the follwing output:

$ ansible-playbook -i inventory/ sops_test.yaml

PLAY [localhost] ***************************************************************

TASK [sops_test : Print regular variable] **************************************
ok: [localhost] => {
    "msg": "sops_test_example value: I got overridden!"
}

TASK [sops_test : Print encrypted value] ***************************************
ok: [localhost] => {
    "msg": "sops_test_password value: this_will_be_encrypted"
}

PLAY RECAP *********************************************************************
localhost  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

As you can notice, the value of sops_test_password variable was successfully read and decrypted during playbook run!

A few tips

Secret encryption using age is considered safe, but make sure that your private key is stored securely. Also, double-check that your Ansible roles don’t expose decrypted secrets in Ansible logs. This is especially important if you’re using Ansible in CI/CD pipelines.

While age is considered a secure encryption method, you can bring the security up a notch by using PGP or KMS encryption method.