README for humans, AGENTS.md for agents and contributors, and the docs that outlive any one platform: architecture, secrets, connectivity, triggering a deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are pointers rather than copies, so every agent and every human reads the same text. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
171 lines
6.9 KiB
Markdown
171 lines
6.9 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.
|
|
|
|
## 2. Per-stack `.env` files
|
|
|
|
Stack secrets live on the hosts, not here.
|
|
|
|
The role excludes `.env` from every sync (`stack_exclude`), so 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.
|
|
|
|
Each example stack that needs credentials ships a `.env.example` listing the
|
|
variable names with no values, and reads them in its compose file with Compose's
|
|
required-variable syntax:
|
|
|
|
```yaml
|
|
environment:
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env on the host}
|
|
```
|
|
|
|
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.
|
|
|
|
`webapp.yml` goes one step further and asserts the file exists on the host 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 webapp .env on the host
|
|
ansible.builtin.stat:
|
|
path: "{{ webapp_dir }}/.env"
|
|
register: webapp_env
|
|
changed_when: false
|
|
|
|
- name: Verify the webapp .env is present on the host
|
|
ansible.builtin.assert:
|
|
that:
|
|
- webapp_env.stat.exists
|
|
fail_msg: >-
|
|
{{ webapp_dir }}/.env is missing on {{ inventory_hostname }}.
|
|
quiet: true
|
|
```
|
|
|
|
Do this for any 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.
|