stack_secrets reads KV v2 on the target host and writes the stack's .env at 0600, failing loudly on a sealed vault, a bad token, a missing field or an unsafe value. Adds an example OpenBao stack on app and moves webapp onto it; metrics keeps a hand-placed .env. Co-Authored-By: Claude Opus 5.5 <[email protected]>
433 lines
17 KiB
Markdown
433 lines
17 KiB
Markdown
# Secrets
|
||
|
||
Three separate things, handled three different ways. The split matters: the first
|
||
is needed to *reach* a host, the second is needed by the software *on* it, and
|
||
the third is a mistake.
|
||
|
||
## 1. The deploy SSH key
|
||
|
||
Ansible knows exactly one thing about credentials: **`SSH_KEY_PATH`**, a path to
|
||
a `0600` private key. `inventory/group_vars/all.yml` feeds it to
|
||
`ansible_ssh_private_key_file` and nothing else in the repository touches key
|
||
material.
|
||
|
||
Everything about *where the key comes from* lives in `scripts/load-ssh-key.sh`,
|
||
which prints a path and never prints the key:
|
||
|
||
```bash
|
||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" # env: already on disk
|
||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh ci)" # ci: from a CI secret
|
||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh vault)" # vault: from Vault / OpenBao
|
||
```
|
||
|
||
Add a backend there rather than teaching a playbook about secrets. That seam is
|
||
the reason the same playbooks run unchanged from a laptop, from GitHub Actions,
|
||
from GitLab CI and from Gitea Actions.
|
||
|
||
Whichever backend you use, the matching **public** key must be in the deploy
|
||
user's `~/.ssh/authorized_keys` on every host, or the key is useless.
|
||
|
||
### Storing it in CI
|
||
|
||
Store the key **base64-encoded**, as a single line:
|
||
|
||
```bash
|
||
base64 -w0 < ~/.ssh/deploy_key # Linux
|
||
base64 < ~/.ssh/deploy_key | tr -d '\n' # macOS
|
||
```
|
||
|
||
```powershell
|
||
# Windows PowerShell
|
||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\deploy_key"))
|
||
```
|
||
|
||
On Windows use exactly that. `certutil -encode` wraps its output in a header and
|
||
line breaks, and `Get-Content` re-encodes the bytes — both produce something that
|
||
looks like base64 and decodes to an unusable key. Do not re-wrap the output
|
||
either; it is one long line by design.
|
||
|
||
Then set it as `SSH_PRIVATE_KEY_B64`:
|
||
|
||
| Platform | Where | Notes |
|
||
| --- | --- | --- |
|
||
| GitHub | Settings → Environments → `production` → Secrets | Scoped to the environment, so only jobs naming it can read it |
|
||
| GitLab | Settings → CI/CD → Variables | Mark **Masked** and **Protected** |
|
||
| Gitea | Settings → Actions → Secrets | No environments and no masking controls: any run in the repository can read it. See [ci-gitea.md](ci-gitea.md) |
|
||
|
||
Why base64 rather than the raw key? GitLab can only *mask* a value that is a
|
||
single line with no whitespace, and an OpenSSH private key is neither. GitHub
|
||
redacts secrets from logs regardless, but a single-line value survives being
|
||
passed between steps without surprises. One encoding satisfies both.
|
||
|
||
A raw `SSH_PRIVATE_KEY` still works — as a GitLab File-type variable, as a
|
||
GitHub multi-line secret, or as a path on disk. The loader accepts all three. It
|
||
just cannot be masked on GitLab, so the key can appear in a job log if something
|
||
echoes it.
|
||
|
||
### Storing it in a vault
|
||
|
||
`vault` reads a HashiCorp Vault or OpenBao KV v2 secret. The expected layout,
|
||
overridable with `VAULT_KV_MOUNT` / `VAULT_SECRET_PATH` / `VAULT_SECRET_FIELD`
|
||
(or the `BAO_*` spellings):
|
||
|
||
```
|
||
kv/infra/ansible/deploy-key field: private_key
|
||
```
|
||
|
||
A read-only policy is enough:
|
||
|
||
```hcl
|
||
path "kv/data/infra/ansible/deploy-key" {
|
||
capabilities = ["read"]
|
||
}
|
||
```
|
||
|
||
**Keep the vault off the deploy path.** If the vault runs on a host this
|
||
repository deploys to, and it seals on reboot, then a deploy that fetches its key
|
||
from the vault cannot run — including the deploy that would fix that host. Host
|
||
it elsewhere, or auto-unseal it. A CI secret is the dependable route.
|
||
|
||
That is also why **the deploy key does not live in the stacks' OpenBao** (§2),
|
||
even when one runs on the host. You need SSH to reach the host the vault runs
|
||
on, so a key fetched from that vault could never get you there in the first
|
||
place. Two stores, deliberately: CI holds the key that reaches the host, the
|
||
vault holds what the stacks need once you are there.
|
||
|
||
## 2. Per-stack secrets
|
||
|
||
Stack secrets never live in this repository. There are two ways to get them onto
|
||
a host, and both end in the same place: a `.env` next to the stack's compose
|
||
file, which Compose reads and the sync never touches (`stack_exclude`).
|
||
|
||
Either way, compose files read each value with Compose's required-variable
|
||
syntax:
|
||
|
||
```yaml
|
||
environment:
|
||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?missing from .env}
|
||
```
|
||
|
||
That is worth copying. Without the `:?`, a missing variable expands to the empty
|
||
string and Postgres starts with **no password at all** — a failure that looks
|
||
like success until someone finds it. With it, Compose refuses to start and names
|
||
the variable. Each stack also ships a `.env.example` listing the names with no
|
||
values, as documentation.
|
||
|
||
### From OpenBao, with `stack_secrets` (recommended)
|
||
|
||
A playbook names what it needs, and the `compose_stack` role renders it:
|
||
|
||
```yaml
|
||
stack_secrets:
|
||
SECRET_KEY:
|
||
path: stacks/webapp
|
||
field: secret_key
|
||
```
|
||
|
||
The role reads `kv/stacks/webapp` from `http://127.0.0.1:8200` **on the target
|
||
host**, at the end of the sync, and writes `{{ stack_dest }}/.env` at `0600`.
|
||
Nothing is placed by hand, and nothing — plaintext or encrypted — is committed.
|
||
`webapp.yml` and `webapp-staging.yml` are the worked examples, reading from the
|
||
`openbao` stack on the same host.
|
||
|
||
A sealed vault, an unset token, a 403 or 404 from the vault, a field that is
|
||
not there, and a value that cannot be safely written to a `.env` all fail before
|
||
the stack starts, each naming what to fix. No task prints a value: the reads are
|
||
`no_log`, and the asserts that explain a failure see only names, paths and
|
||
status codes.
|
||
|
||
#### Why the read happens on the host
|
||
|
||
Because the vault is published to `127.0.0.1:8200` and nowhere else. Ansible
|
||
tasks already run on the host, so the store never needs a public hostname, never
|
||
needs a certificate, and does not care whether the deploy came from a laptop or
|
||
a CI runner. `ansible.cfg` sets `pipelining = True`, so module arguments are
|
||
piped to the remote interpreter over stdin and the token is never written to a
|
||
file on the host. That covers the token specifically — it does not make every
|
||
transfer file-free: `ansible.builtin.copy` still lands its content as a `0600`
|
||
temp file under `~/.ansible/tmp` on both ends, removed when the run ends,
|
||
regardless of pipelining.
|
||
|
||
The consequence is **one vault per host that reads from it**, or a `vault_addr`
|
||
the host can reach. In this repository only `app` runs one; `metrics`, which
|
||
also runs on `edge`, uses a hand-placed `.env` instead (below).
|
||
|
||
#### The token
|
||
|
||
`VAULT_TOKEN` is read from the control node's environment: your shell locally,
|
||
and a CI secret in a runner. It carries a `stacks-read` policy granting `read`
|
||
on `kv/data/stacks/*` and nothing else. It is **not** the root token.
|
||
|
||
The name is not configurable. `compose_stack` reads it with
|
||
`lookup('env', 'VAULT_TOKEN')`, and the deploy workflows pass it through as an
|
||
environment variable rather than interpolating it into a `run:` body — an
|
||
expression is substituted before the shell sees it, so a value containing shell
|
||
metacharacters would be executed rather than assigned.
|
||
|
||
Where to put it, and how long it lasts, are steps 5 and 6 of *Bootstrapping the
|
||
vault* below.
|
||
|
||
#### Unsealing
|
||
|
||
OpenBao has no self-contained auto-unseal; it needs an external KMS or a second
|
||
vault. The example stack is unsealed by hand:
|
||
|
||
```bash
|
||
ssh [email protected]
|
||
docker exec -it openbao bao operator unseal # three times, three different keys
|
||
```
|
||
|
||
(`docker exec` inherits the container's environment, including the `BAO_ADDR`
|
||
set in `docker-compose.yml` — so none of the commands on this page need their
|
||
own `-e BAO_ADDR=...`. Without it the `bao` CLI defaults to
|
||
`https://127.0.0.1:8200` and every one of these fails with a TLS handshake
|
||
error, since the listener has TLS disabled.)
|
||
|
||
After a reboot the vault is sealed and any deploy that reads a secret fails,
|
||
naming the vault and pointing here. That is the intended behaviour: a sealed
|
||
vault is a loud, safe failure.
|
||
|
||
#### Adding a secret
|
||
|
||
Putting the root token and the secret value on the command line, as
|
||
`-e BAO_TOKEN=<root>` and `<field>=<value>`, lands both in the host's process
|
||
table and in the operator's shell history. Log in interactively instead, and
|
||
pipe the value in on stdin so it never appears as an argument:
|
||
|
||
```bash
|
||
ssh [email protected]
|
||
docker exec -it openbao bao login # prompts for the root token
|
||
|
||
read -rs SECRET
|
||
printf '%s' "$SECRET" | docker exec -i openbao bao kv put kv/stacks/<stack> <field>=-
|
||
unset SECRET
|
||
```
|
||
|
||
`read -rs` does not echo and does not reach shell history, and `printf '%s'`
|
||
adds no trailing newline. **That last part matters.** `<field>=-` stores stdin
|
||
byte for byte, including the newline you press Enter for, and the role refuses
|
||
to render a value containing a newline or a single quote — a `.env` line it
|
||
cannot quote safely is one Compose would misread. The deploy then fails on
|
||
*Verify no secret value contains a newline or a single quote*, which is the
|
||
guard working, but it is a round trip you can skip.
|
||
|
||
Generating a value has the same trap, because every generator ends its output
|
||
with a newline:
|
||
|
||
```bash
|
||
openssl rand -hex 32 | tr -d '\n' | docker exec -i openbao bao kv put kv/stacks/<stack> <field>=-
|
||
```
|
||
|
||
`kv put` replaces every field at that path. To add a second field beside an
|
||
existing one, use `bao kv patch` instead, or the first is lost.
|
||
|
||
Then add the entry to the playbook's `stack_secrets` and deploy. Rotating is the
|
||
same `kv put` (or `kv patch`) followed by a redeploy.
|
||
|
||
Removing an entry from `stack_secrets` stops the render on the next deploy; it
|
||
does not delete the `.env` already on the host, which keeps serving its stale
|
||
value until something else removes it.
|
||
|
||
#### A changed openbao.hcl needs a manual restart
|
||
|
||
Restarting OpenBao seals it. `openbao.yml` therefore reports that its payload
|
||
changed rather than restarting the container, because an automatic restart in
|
||
the middle of `site.yml` would seal the vault and fail every stack imported
|
||
after it. Restart and unseal deliberately.
|
||
|
||
#### Bootstrapping the vault
|
||
|
||
Hosting the vault on the host it serves is circular, so the order is fixed and
|
||
the middle of it is manual. This is the whole sequence, once per vault.
|
||
|
||
**1. Prepare the host and deploy the stack.**
|
||
|
||
```bash
|
||
cd ansible
|
||
ansible-playbook playbooks/bootstrap.yml -u <admin-user> -k -K -e deploy_sudo=true
|
||
ansible-playbook playbooks/openbao.yml
|
||
```
|
||
|
||
`deploy_sudo` is not optional here. `openbao.yml` hands `data/` to uid 100, and a
|
||
`stack_dirs` entry with an `owner` is the only thing the role escalates for.
|
||
Without it the deploy stops at *Create the extra directories the stack needs*
|
||
with `Missing sudo password`. The stack declares no `stack_secrets` of its own —
|
||
it is the root of trust and cannot depend on itself — so it comes up with
|
||
nothing in place. See [bootstrap.md](bootstrap.md).
|
||
|
||
**2. Initialise, once, ever.**
|
||
|
||
```bash
|
||
ssh [email protected]
|
||
docker exec -it openbao bao operator init
|
||
```
|
||
|
||
Five unseal keys and one root token are printed **once**. They are not written
|
||
to the host and cannot be recovered. Put all six in your password manager before
|
||
doing anything else: three of the five unseal the vault, and losing three makes
|
||
the storage unreadable permanently.
|
||
|
||
**3. Unseal.**
|
||
|
||
```bash
|
||
docker exec -it openbao bao operator unseal # three times, three different keys
|
||
docker exec openbao bao status # expect: Sealed false
|
||
```
|
||
|
||
**4. Create the mount and the read-only policy.**
|
||
|
||
Log in interactively rather than passing `-e BAO_TOKEN=<root>`, for the reason
|
||
under *Adding a secret* above — an argument lands in the host's process table.
|
||
|
||
```bash
|
||
docker exec -it openbao bao login # prompts for the root token
|
||
|
||
docker exec openbao bao secrets enable -path=kv kv-v2
|
||
|
||
printf 'path "kv/data/stacks/*" {\n capabilities = ["read"]\n}\n' \
|
||
| docker exec -i openbao bao policy write stacks-read -
|
||
```
|
||
|
||
**5. Mint the deploy token and store it.**
|
||
|
||
```bash
|
||
docker exec openbao bao token create \
|
||
-policy=stacks-read -period=768h -display-name=ansible
|
||
```
|
||
|
||
This token, never the root one, is `VAULT_TOKEN`. Locally it is an export:
|
||
|
||
```bash
|
||
export VAULT_TOKEN=<the stacks-read token>
|
||
```
|
||
|
||
In CI it is a secret of the same name:
|
||
|
||
| Platform | Where | Notes |
|
||
| --- | --- | --- |
|
||
| GitHub | Settings → Environments → `production` → Secrets | Scoped to the environment, so only jobs naming it can read it |
|
||
| GitLab | Settings → CI/CD → Variables | Mark **Masked** and **Protected** |
|
||
| Gitea | Settings → Actions → Secrets | No environments and no masking controls: any run in the repository can read it — which is precisely why the token is scoped. See [ci-gitea.md](ci-gitea.md) |
|
||
|
||
Unlike the SSH key in §1, this one needs no base64: a vault token is already a
|
||
single line with no whitespace, which is the only thing GitLab's masking
|
||
requires.
|
||
|
||
**6. Diarise the expiry.**
|
||
|
||
`-period=768h` makes the token renewable indefinitely; it does not make it
|
||
permanent. It lapses 32 days after it was last renewed, and the first sign is a
|
||
deploy failing on the vault read after a quiet month.
|
||
|
||
Re-minting is the reliable fix — repeat step 5 and replace the secret. To renew
|
||
instead, log in as the token rather than passing it as an argument, where the
|
||
host's process table would keep it:
|
||
|
||
```bash
|
||
docker exec -it openbao bao login # paste the stacks-read token
|
||
docker exec openbao bao token renew # renews the token you logged in with
|
||
docker exec openbao sh -c 'rm -f ~/.bao-token'
|
||
```
|
||
|
||
A longer `-period` at mint time is the other lever, traded against how long a
|
||
leaked token stays useful.
|
||
|
||
**7. Prove the token cannot write.**
|
||
|
||
```bash
|
||
docker exec -e BAO_TOKEN=<the new token> openbao bao kv put kv/stacks/canary value=nope
|
||
```
|
||
|
||
This must fail with a 403. If it succeeds the policy is wrong and anything
|
||
holding the deploy token can write to the store. Nothing else surfaces a policy
|
||
typo, so do not skip it.
|
||
|
||
**8. Load the secrets and converge.**
|
||
|
||
Once per secret, per *Adding a secret* above — mind the trailing newline. For
|
||
the examples that is `postgres_password` and `secret_key` under
|
||
`kv/stacks/webapp`, and the same two under `kv/stacks/webapp-staging`:
|
||
|
||
```bash
|
||
openssl rand -hex 32 | tr -d '\n' \
|
||
| docker exec -i openbao bao kv put kv/stacks/webapp postgres_password=-
|
||
openssl rand -hex 32 | tr -d '\n' \
|
||
| docker exec -i openbao bao kv patch kv/stacks/webapp secret_key=-
|
||
```
|
||
|
||
Then converge with `ansible-playbook playbooks/site.yml` from `ansible/`.
|
||
|
||
When you are finished, `bao login` has left the root token in the container at
|
||
`~/.bao-token`; remove it with `docker exec openbao sh -c 'rm -f ~/.bao-token'`.
|
||
|
||
Steps 2–7 are manual and stay manual. That is the accepted cost of not having a
|
||
second machine to hold the keys.
|
||
|
||
### By hand, on the host
|
||
|
||
A stack with no `stack_secrets` never talks to a vault, and reads a `.env`
|
||
someone placed on the host. Editing one in this repository has no effect, and a
|
||
host's `.env` is never overwritten by a deploy. To change one, edit it on the
|
||
host and redeploy.
|
||
|
||
`metrics` works this way, because it runs on `edge` too and `edge` has no
|
||
vault. Its playbook asserts the file exists before doing anything, so the error
|
||
arrives before the payload is synced rather than halfway through starting the
|
||
stack:
|
||
|
||
```yaml
|
||
pre_tasks:
|
||
- name: Look for the metrics .env on the host
|
||
ansible.builtin.stat:
|
||
path: "{{ stack_root }}/metrics/.env"
|
||
register: metrics_env
|
||
changed_when: false
|
||
|
||
- name: Verify the metrics .env is present on the host
|
||
ansible.builtin.assert:
|
||
that:
|
||
- metrics_env.stat.exists
|
||
fail_msg: >-
|
||
{{ stack_root }}/metrics/.env is missing on {{ inventory_hostname }}.
|
||
quiet: true
|
||
```
|
||
|
||
Do this for any hand-placed stack whose compose file has no safe defaults.
|
||
|
||
## 3. Secrets committed to the repository
|
||
|
||
There is no third category. This section exists because it is the failure mode
|
||
this layout is designed to prevent, and it is worth being blunt about why.
|
||
|
||
A credential written directly into a compose file:
|
||
|
||
- **cannot be excluded from the sync.** `stack_exclude` keeps `.env` off the
|
||
wire; it can do nothing about a value inside `docker-compose.yml`.
|
||
- **is in every clone, forever.** Git history is the point of git. Deleting the
|
||
file in a later commit changes nothing about the credential.
|
||
- **cannot be rotated without a deploy**, so it usually is not rotated at all.
|
||
- **escapes its blast radius.** A personal access token carries its creator's
|
||
access to everything they can reach, not just the project it was added to. A
|
||
cloud application secret outlives the VM it was used on.
|
||
|
||
If you find one, the fix is to **rotate the credential**, not to delete the file.
|
||
Removing it stops new clones carrying it and changes nothing else. Then move it
|
||
to a `.env` on the host and read it with `${VAR:?}`.
|
||
|
||
### The guard
|
||
|
||
The `lint` workflow — [`.github/workflows/lint.yml`](../.github/workflows/lint.yml),
|
||
[`.gitea/workflows/lint.yml`](../.gitea/workflows/lint.yml) and the `lint` job in
|
||
`.gitlab-ci.yml` — runs [gitleaks](https://github.com/gitleaks/gitleaks) on every
|
||
push, over the working tree **and** the full history: a secret that was committed
|
||
and then removed is still leaked, and the scan should say so. `.gitleaks.toml`
|
||
allowlists only `*.env.example` files and this document.
|
||
|
||
On GitHub it runs the upstream Docker image rather than the marketplace action on
|
||
purpose: the action requires a licence key for organisation-owned repositories
|
||
and silently does nothing without one, which is the worst possible behaviour for
|
||
a secret scanner. The Gitea port runs the pinned release binary instead, because
|
||
a job there is itself a container with no Docker socket.
|
||
|
||
Do not add an allowlist entry to make a finding go away. Rotate the credential.
|