Skip to main content

Vault Deployer

Ansible automation that deploys a single-node secrets vault onto a remote Linux host.

This is the secret store the rest of the fleet reads from, so it bootstraps without one: every secret it needs comes from the environment.

What it deploys​

A single digest-pinned Docker container, using the vault's own integrated Raft storage, plus a UFW rule that allows the listener port.

The listener requires mutual TLS:

BoundaryServer presentsClient must present
Client -> VaultServer certificateA client certificate signed by the root CA

Prerequisites​

On the control machine: uv.

On the target host: a Debian-based system with Docker, UFW (the playbook adds a rule for the listener port and enables UFW), and password-less sudo for the SSH user. The playbook installs the python3-httpx and python3-docker packages with APT.

The inventory must hold a single remote host besides localhost; the playbook fails if it holds more than one. post-install also runs a client on the control machine, which must be able to reach the vault's listener port.

Configuration​

Every secret is read from the environment. Export them (or source an env file) before running the playbook.

VariableDescription
CS_PROJECT_CODEProject code, the main org/bucket name
ROOT_CA_CERT_PEM_BASE64Root CA certificate, the single trust anchor
ROOT_CA_KEY_PEM_BASE64Root CA private key, used to sign every leaf
ROOT_CA_KEY_PASSWORD_BASE64Passphrase for the root CA key
ROOT_CA_DOMAINDomain the root CA issues certificates for
VAULT_ADMIN_SERVICE_PASSWORDPassword for the admin user post-install creates

Set DEBUG=true to print task output that is otherwise hidden with no_log. It exposes secrets, so never use it in production.

All three root CA values are base64 encoded, the passphrase included, because its raw form does not survive a shell export.

The root CA is the only certificate material supplied from outside. The server certificate and the client certificate are both issued during the run.

The domain (the host's ansible_host by default), ports, container name, and image digest live in inventory.yml.

Usage​

uv sync
uv --offline run --no-sync --no-progress ansible-galaxy install -r requirements.yml

set -a && source .env && set +a
uv --offline run --no-sync --no-progress ansible-playbook playbook.yml -l <host>

The playbook deploys a single instance, so target one host with -l <host>.

Stages​

Each stage has a tag and can be run on its own:

TagWhat it does
applicationIssues the server certificate, deploys the container
post-installIssues the client certificate, validates the mutual TLS listener end to end, initializes and unseals the vault, configures access
autounsealDeploys a script and cron job that unseal the vault automatically whenever it becomes sealed

Every stage tag also runs the preparation step (host packages and the install directory), which can also run on its own with --tags prepare. The prerequisite checks run on every invocation.

uv --offline run --no-sync --no-progress ansible-playbook playbook.yml -l <host> --tags application

One stage is opt-in and never runs unless asked for by name:

uv --offline run --no-sync --no-progress ansible-playbook playbook.yml -l <host> --tags cleanup

cleanup removes the autounseal cron job, the vault container, and the whole install directory, /app/vault, including the certificates and vault-init.json.

Reaching the vault​

The listener requires a client certificate. The playbook issues one and leaves it on the target host under /app/vault/client-certs/ (ca.crt, client.crt, and client.key):

curl --cacert ca.crt --cert client.crt --key client.key https://<host>:9744/v1/sys/seal-status

Pass -e vs_force_rotate_leaf_certs=true to force every private key, CSR, and certificate the playbook manages, server and client alike, through a fresh issuance. Existing certificates keep working as long as they were signed by the same root CA.

Certificate lifetimes​

The server and client certificates are valid for ten years.

Initialization and unsealing​

The first time the playbook runs against a fresh vault, post-install initializes it with five key shares and a threshold of three, and writes the response, unseal keys and root token included, to /app/vault/vault-init.json on the target host. Keep that file safe: it is the only copy. On every later run the vault is already initialized, so this step does nothing.

Whenever the vault is sealed, post-install unseals it using the key shares from that same file, cancelling any unseal already in progress first. Once it is unsealed, this step does nothing.

On every run, once the vault is unsealed, post-install reads the root token back out of vault-init.json and revokes it with its own self-revoke call. The first run revokes it; on later runs the token is already gone and the call changes nothing. That token is never used for anything else; a fresh one is generated next for the rest of the run's admin setup. The unseal keys in vault-init.json remain valid and are still needed on every later run.

Automatic unsealing​

autounseal writes a script to the target host and schedules it on a root cron job that runs every minute. Each run checks the seal status over the same mutual TLS connection as the rest of the deployment, using the client certificate bundle post-install issues, and does nothing once the vault is unsealed. If the vault is sealed, it cancels any unseal already in progress and submits the key shares from vault-init.json, the same file post-install reads from. Every request it makes carries a three second timeout. Its output is appended to a log file next to that script.

Admin access​

post-install also generates a fresh root token, then mounts a KV version 2 secrets engine at each of managed-services and CS_PROJECT_CODE.

It writes two ACL policies: sudo, granting full access to every path, and automation-pipeline-CS_PROJECT_CODE, granting create, read, update, delete and list access scoped to the two KV version 2 mounts. It enables and tunes the userpass and approle auth methods (30-day default and 90-day maximum leases, listed on the unauthenticated login page), and creates an admin user under userpass with both the default and sudo policies attached. The username comes from vs_admin_username in inventory.yml (svc_vault_root by default); its password comes from VAULT_ADMIN_SERVICE_PASSWORD.

Under approle it creates the role pipeline-CS_PROJECT_CODE-role, with the default and automation-pipeline-CS_PROJECT_CODE policies, a role ID equal to the role name, one-day tokens (three-day maximum), and secret IDs that expire after 90 days or 10000 uses.

Then it seeds the CS_PROJECT_CODE mount with the root CA material and its domain, at certificate-authority/root-ca. The control machine writes this secret, over the same mutual TLS connection, so it must reach the listener port.

Finally, post-install runs the token cleanup step in dry-run mode: it lists every token accessor and AppRole secret ID accessor that a cleanup would revoke or destroy, and revokes nothing. The root token generated during the run therefore stays valid after the run finishes, and a fresh one is generated the next time the playbook runs.

Development​

See DEVELOPMENT.md for the syntax-check, lint, and test commands.