2026-09-03 04:24:38 +01:00
|
|
|
# 10 — Secrets: SOPS + age
|
|
|
|
|
|
|
|
|
|
This repo is public. A Kubernetes `Secret` only **base64-encodes** its data
|
|
|
|
|
— that's encoding, not encryption, trivially reversible by anyone who can
|
|
|
|
|
read the YAML. Never commit one directly. This doc sets up the tool this
|
|
|
|
|
repo actually uses instead: **SOPS** (Secrets OPerationS) encrypting with
|
|
|
|
|
**age**, decrypted inside the cluster by a small operator so committed files
|
|
|
|
|
stay encrypted end to end, right up until the moment they become a real
|
|
|
|
|
Kubernetes `Secret`.
|
|
|
|
|
|
|
|
|
|
## The pieces
|
|
|
|
|
|
|
|
|
|
- **age** — a modern, simple encryption tool. A keypair: a **public key**
|
|
|
|
|
(safe to share, can only encrypt) and a **private key** (must never leave
|
|
|
|
|
your machine and the cluster, can decrypt).
|
|
|
|
|
- **sops** — a CLI that encrypts specific fields inside a YAML/JSON file
|
|
|
|
|
using a recipient (here, your age public key), leaving the rest of the
|
|
|
|
|
file's structure readable so diffs in Git still make sense.
|
|
|
|
|
- **[sops-secrets-operator](https://github.com/isindir/sops-secrets-operator)**
|
|
|
|
|
— a small controller running inside the cluster, installed exactly like
|
|
|
|
|
any other app in this repo (see
|
|
|
|
|
[`apps/sops-secrets-operator/`](../apps/sops-secrets-operator)). It
|
|
|
|
|
watches for a custom resource called `SopsSecret` — an ordinary,
|
|
|
|
|
SOPS-encrypted YAML file, safe to commit — and decrypts it into a real
|
|
|
|
|
`Secret` automatically.
|
|
|
|
|
|
|
|
|
|
## The one manual step: getting the private key into the cluster
|
|
|
|
|
|
|
|
|
|
Every other piece of this repo reaches the cluster through Git and Argo
|
|
|
|
|
CD. The age **private** key is the one deliberate exception — same idea as
|
|
|
|
|
Argo CD's own install in [`docs/03-installing-argocd.md`](03-installing-argocd.md):
|
|
|
|
|
something has to exist outside the loop to start it, and a decryption key
|
|
|
|
|
is exactly the kind of thing that must never be *in* Git to begin with.
|
|
|
|
|
|
|
|
|
|
1. Generate the keypair once, on your own machine:
|
|
|
|
|
```sh
|
|
|
|
|
mkdir -p ~/.config/sops/age
|
|
|
|
|
age-keygen -o ~/.config/sops/age/keys.txt
|
|
|
|
|
```
|
|
|
|
|
This prints a `Public key: age1...` line — copy it into
|
|
|
|
|
[`.sops.yaml`](../.sops.yaml) at the repo root (already done for this
|
|
|
|
|
cluster). The public key is safe to commit; the file it's printed from
|
|
|
|
|
is not.
|
|
|
|
|
|
|
|
|
|
2. Put the private key into the cluster, by hand, once:
|
|
|
|
|
```sh
|
|
|
|
|
kubectl create namespace sops
|
|
|
|
|
kubectl create secret generic sops-age-key-file \
|
|
|
|
|
--namespace sops \
|
|
|
|
|
--from-file=key=$HOME/.config/sops/age/keys.txt
|
|
|
|
|
```
|
|
|
|
|
[`apps/sops-secrets-operator/values.yaml`](../apps/sops-secrets-operator/values.yaml)
|
|
|
|
|
mounts this exact Secret into the operator so it can decrypt — the
|
|
|
|
|
operator's own Application manifest never contains the key itself, only
|
|
|
|
|
a reference to a Secret you created directly.
|
|
|
|
|
|
|
|
|
|
3. Back up `~/.config/sops/age/keys.txt` somewhere safe and private (a
|
|
|
|
|
password manager, not this repo). If you lose both this file and the
|
|
|
|
|
in-cluster Secret, every `SopsSecret` ever committed becomes
|
|
|
|
|
permanently unreadable — there is no recovery path.
|
|
|
|
|
|
|
|
|
|
## Creating a new secret
|
|
|
|
|
|
|
|
|
|
Say you need to store a Cloudflare API token for `cert-manager`
|
|
|
|
|
(see [`docs/12-cert-manager.md`](12-cert-manager.md)). Write the *plaintext*
|
|
|
|
|
version first, as a `SopsSecret` — this is the custom resource the operator
|
|
|
|
|
watches for, not a plain `Secret`:
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
# apps/cert-manager/cloudflare-api-token.sops.yaml
|
|
|
|
|
apiVersion: isindir.github.com/v1alpha3
|
|
|
|
|
kind: SopsSecret
|
|
|
|
|
metadata:
|
|
|
|
|
name: cloudflare-api-token
|
|
|
|
|
namespace: cert-manager
|
|
|
|
|
spec:
|
|
|
|
|
secretTemplates:
|
|
|
|
|
- name: cloudflare-api-token
|
|
|
|
|
stringData:
|
|
|
|
|
api-token: PASTE_THE_REAL_TOKEN_HERE
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Then encrypt it **in place** — this is the step that actually protects it,
|
|
|
|
|
and it must happen before this file is ever `git add`ed:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
sops --encrypt --in-place apps/cert-manager/cloudflare-api-token.sops.yaml
|
|
|
|
|
```
|
|
|
|
|
|
2026-09-03 04:31:03 +01:00
|
|
|
`sops` reads `.sops.yaml`, sees the filename ends in `.sops.yaml`, and —
|
|
|
|
|
because of that file's `encrypted_regex: ^(data|stringData)$` — encrypts
|
|
|
|
|
only the values under `stringData`, for your age public key. `apiVersion`,
|
|
|
|
|
`kind`, and everything under `metadata` (name, namespace) stay in plain
|
|
|
|
|
text on purpose: Kubernetes object names have to be plain DNS-safe
|
|
|
|
|
strings, and encrypting them would produce a `SopsSecret` that fails to
|
|
|
|
|
apply at all. Open the file afterward — `stringData`'s values are now
|
|
|
|
|
unintelligible ciphertext with `sops` metadata appended; everything else is
|
|
|
|
|
still readable. That's what's safe to commit.
|
2026-09-03 04:24:38 +01:00
|
|
|
|
|
|
|
|
Once pushed and synced, the operator finds this `SopsSecret`, decrypts it
|
|
|
|
|
using the private key mounted from step 2, and creates an ordinary
|
|
|
|
|
`Secret` named `cloudflare-api-token` in the `cert-manager` namespace —
|
|
|
|
|
exactly as if you'd run `kubectl create secret` by hand, just reproducible
|
|
|
|
|
and driven from Git like everything else here.
|
|
|
|
|
|
|
|
|
|
## Editing an existing secret
|
|
|
|
|
|
|
|
|
|
Never hand-edit the ciphertext. Decrypt to your terminal, edit, and
|
|
|
|
|
re-encrypt:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
sops apps/cert-manager/cloudflare-api-token.sops.yaml
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Run with no flags, `sops` decrypts the file into a temporary copy, opens
|
|
|
|
|
your `$EDITOR`, and re-encrypts it back to disk on save — you never see or
|
|
|
|
|
create a separate plaintext file on your own disk.
|
|
|
|
|
|
|
|
|
|
## Checklist
|
|
|
|
|
|
|
|
|
|
- [ ] `age-keygen` run once; `~/.config/sops/age/keys.txt` backed up
|
|
|
|
|
somewhere safe, outside this repo
|
|
|
|
|
- [ ] Public key present in [`.sops.yaml`](../.sops.yaml)
|
|
|
|
|
- [ ] `sops-age-key-file` Secret created by hand in the `sops` namespace
|
|
|
|
|
- [ ] `sops-secrets-operator` Application `Synced`/`Healthy`
|
|
|
|
|
- [ ] `argocd app list` shows `sops-secrets-operator` alongside the others
|
|
|
|
|
|
|
|
|
|
Continue to [`docs/11-ingress.md`](11-ingress.md), which comes first
|
|
|
|
|
(cert-manager depends on Ingress existing).
|