Skip to content

Commit cd6e5bc

Browse files
authored
feat(secrets): add SOPS + age secrets management with stackctl.sh integration (#491)
* chore(secrets): update .sops.yaml for service-local .env.enc pattern * docs: update README with secrets workflow reference * docs(secrets): update Phase 1 status in proposal * docs(secrets): rewrite for service-local .env.enc workflow * feat(secrets): add secrets subcommand to stackctl.sh Adds encrypt, decrypt, deploy, and clean operations for managing service-local .env.enc files with SOPS + age. Deploy decrypts, renders, deploys, and shreds plaintext .env in one step. * chore(secrets): allowlist .env.enc in gitignore * docs(secrets): update all READMEs and proposal for consistency - stacks/README.md: add secrets deploy note to doctor and stackctl sections - README.md: fix duplicate section numbering (two section 5s → 5 and 6) - proposal: update stale secrets/ paths to service-local .env.enc pattern - proposal: fix docs/secrets.md reference → docs/Managing Secrets.md * chore: remove .mcp.json and .opencode.json, add design spec * fix: harden secrets handling per PR #491 review - per-operation tool checks (no sops/age required for clean) - service selection now accepts basename or repo-relative path - encrypt writes to temp file, atomically moves to .env.enc - decrypt uses umask 077 + temp file + atomic move - deploy decrypt and cleanup follow same secure pattern - precise stack detection via env_file path match (no loose grep) - deploy cleanup only shreds files decrypted in this run - .sops.yaml path_regex covers both .env and .env.enc - replace globstar key-rotation example with portable find -exec
1 parent 30516d1 commit cd6e5bc

10 files changed

Lines changed: 573 additions & 84 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.env
22
.env.bak*
3+
!*.env.enc
34
data
45
htpasswd
56
*.log

.mcp.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

.opencode.json

Lines changed: 0 additions & 13 deletions
This file was deleted.

.sops.yaml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
# SOPS configuration for encrypting secrets committed to the repo
1+
# SOPS configuration for encrypting service-local .env files
2+
# Each service directory (e.g., postgres/, traefik/) stores an encrypted .env.enc
3+
# alongside its .env.example. Decrypt with: stackctl.sh secrets decrypt [service]
24
creation_rules:
3-
- path_regex: secrets/.*\.(env|yaml|yml)$
4-
# Replace with your age public key(s)
5-
age:
6-
- "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Replace with your real age public key
5+
- path_regex: \.env(\.enc)?$
6+
key_groups:
7+
- age:
8+
- age12ph7sgtptrrcxzxdue28j3lesnu9gj73ae9ewuvg66awp6jpae8smyvspx
79
encrypted_regex: '^(?!#)'

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ cd ../portainer
234234
docker-compose up -d
235235
```
236236

237-
### 5. Access the services
237+
### 6. Access the services
238238

239239
- Grafana: <https://grafana.your-domain.com>
240240
- Prometheus: <https://prometheus.your-domain.com>
@@ -261,12 +261,22 @@ local-stack/
261261

262262
## Configuration
263263

264-
Each component has its own environment file for configuration. Copy the example files and modify as needed:
264+
Each component has its own environment file for configuration. For local development without secrets management:
265265

266266
```sh
267267
find . -name ".env.example" -exec sh -c 'cp "$1" "${1%.example}"' _ {} \;
268268
```
269269

270+
For production or shared environments, use the SOPS + age secrets workflow (see [Managing Secrets](docs/Managing%20Secrets.md)):
271+
272+
```sh
273+
# Decrypt and deploy in one step
274+
./stackctl.sh secrets deploy
275+
276+
# Or decrypt manually for inspection
277+
./stackctl.sh secrets decrypt
278+
```
279+
270280
## License
271281

272282
```txt

docs/Managing Secrets.md

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,142 @@
11
# Managing Secrets Securely
22

3-
Never store real secrets in `.env` files in the repo. Keep `.env.example` as documentation with placeholders only.
3+
Secrets are encrypted at rest in git using SOPS + age and decrypted just-in-time at deploy time. Each service directory stores an encrypted `.env.enc` alongside its `.env.example`.
44

5-
## Recommended approach (encrypted files committed to git)
5+
## Workflow Overview
66

7-
Use `sops` + age (or GPG) to encrypt per-environment secrets that can be safely committed.
7+
```
8+
.env.example → .env (fill values) → .env.enc (encrypt) → git commit
9+
10+
.git (encrypted) → .env.enc → .env (decrypt) → deploy → shred .env
11+
```
12+
13+
## Setup
814

9-
### 1) Install tools
10-
- age: https://age-encryption.org
11-
- sops: https://github.com/getsops/sops
15+
### 1. Install tools
1216

13-
### 2) Create an age key pair (once)
1417
```bash
15-
# writes to ~/.config/sops/age/keys.txt
18+
# macOS
19+
brew install sops age
20+
21+
# Linux
22+
# sops: https://github.com/getsops/sops/releases
23+
# age: https://github.com/FiloSottile/age/releases
24+
```
25+
26+
### 2. Generate an age key pair (once per machine)
27+
28+
```bash
29+
mkdir -p ~/.config/sops/age
1630
age-keygen -o ~/.config/sops/age/keys.txt
31+
# The public key is printed — add it to .sops.yaml key_groups
1732
```
18-
Add the public recipient from that file (starts with `age1...`) to your repository SOPS config.
1933

20-
### 3) Add a SOPS config
21-
Create `.sops.yaml` at repo root:
34+
### 3. Add your public key to `.sops.yaml`
35+
36+
Edit `.sops.yaml` and add your age public key to the `key_groups` list. Multiple recipients can be listed for team access:
37+
2238
```yaml
23-
# Encrypt files matching these globs with the recipient below
2439
creation_rules:
25-
- path_regex: secrets/.*\.(env|yaml|yml)$
26-
age: ["AGE1_PUBLIC_KEY_HERE"]
27-
encrypted_regex: '^(?!#)'
40+
- path_regex: \.env(\.enc)?$
41+
key_groups:
42+
- age:
43+
- age1existingkey... # existing recipient
44+
- age1yournewkey... # your new key
2845
```
29-
Replace `AGE1_PUBLIC_KEY_HERE` with your public age key.
3046
31-
### 4) Create encrypted secret files
32-
Place per-environment secrets under `secrets/` and encrypt with sops:
47+
## Encrypting secrets
48+
49+
For a single service:
50+
3351
```bash
34-
mkdir -p secrets
35-
printf "TRAEFIK_ENABLE=true\nSSO_CREDENTIALS=admin:$apr1$...\n" > secrets/traefik.dev.env
36-
sops -e -i secrets/traefik.dev.env
52+
# Create .env from .env.example and fill in real values
53+
cp postgres/.env.example postgres/.env
54+
# Edit postgres/.env with real values...
55+
56+
# Encrypt
57+
./stackctl.sh secrets encrypt postgres
3758
```
38-
The file is now encrypted at rest and safe to commit.
3959

40-
### 5) Decrypt for local use
60+
For all services at once:
61+
4162
```bash
42-
# Produces a plaintext file for docker usage (do not commit this)
43-
sops -d secrets/traefik.dev.env > traefik/.env
63+
./stackctl.sh secrets encrypt
4464
```
45-
You can add a simple make/script target to automate decrypt -> deploy -> clean.
4665

47-
### 6) CI/CD or remote deploy
48-
On a deployment host, provision the age private key (read-only, secured). Decrypt secrets just-in-time before `docker stack deploy`.
66+
This runs `sops --encrypt --input-type dotenv --output-type dotenv .env > .env.enc` for each service directory that has a `.env` file.
4967

50-
## Using Docker Swarm secrets (optional/advanced)
51-
Docker Swarm supports native secrets. You can combine sops+age with `docker secret create`:
68+
## Deploying
69+
70+
Deploy decrypts, renders, deploys, and shreds in one step:
5271

53-
1) Decrypt locally in memory and pipe to secret create:
5472
```bash
55-
sops -d secrets/traefik.dev.env | docker secret create traefik_env -
73+
# Deploy a specific service's stack
74+
./stackctl.sh secrets deploy postgres
75+
76+
# Deploy all services
77+
./stackctl.sh secrets deploy
5678
```
57-
2) Reference the secret in your stack file using `secrets:` and `env_file` alternatives where appropriate.
5879

59-
This is more granular and keeps values out of env vars in the container filesystem, but requires adjusting service configs to read from files or environment sourced from secrets.
80+
The deploy operation:
81+
1. Decrypts `.env.enc``.env` for each target service
82+
2. Regenerates rendered stack files (variable substitution)
83+
3. Deploys the relevant Docker Swarm stacks
84+
4. Shreds all plaintext `.env` files
85+
86+
## Decrypting (manual)
87+
88+
If you need to inspect or edit secrets without deploying:
89+
90+
```bash
91+
# Decrypt a single service
92+
./stackctl.sh secrets decrypt postgres
93+
94+
# Decrypt all services
95+
./stackctl.sh secrets decrypt
96+
```
97+
98+
**Remember to clean up plaintext files after editing:**
99+
100+
```bash
101+
./stackctl.sh secrets clean
102+
```
103+
104+
## Cleaning up
105+
106+
Remove all plaintext `.env` files that have a corresponding `.env.enc`:
107+
108+
```bash
109+
./stackctl.sh secrets clean
110+
```
111+
112+
This uses `shred -u` when available, falling back to `rm -f` on systems without `shred`.
113+
114+
## Key rotation
115+
116+
After adding a new recipient to `.sops.yaml`:
117+
118+
```bash
119+
find . -name '.env.enc' -exec sops updatekeys --yes {} \;
120+
```
121+
122+
## Re-encrypting after editing
123+
124+
```bash
125+
# Decrypt, edit, re-encrypt
126+
./stackctl.sh secrets decrypt postgres
127+
# Edit postgres/.env...
128+
./stackctl.sh secrets encrypt postgres
129+
./stackctl.sh secrets clean
130+
```
60131

61132
## Git hygiene
62-
- Commit only `.env.example` files and encrypted files under `secrets/`.
63-
- Never commit plaintext `.env`.
64-
- Add a `.gitignore` rule for `**/.env` and `**/*.env.decrypted` as needed.
133+
134+
- **Commit**: `.env.example` (placeholders) and `.env.enc` (encrypted secrets)
135+
- **Never commit**: `.env` (plaintext, gitignored)
136+
- The `.gitignore` rule `!*.env.enc` ensures encrypted files are tracked
65137

66138
## Troubleshooting
67-
- If `sops` can’t decrypt: ensure the age private key is in `~/.config/sops/age/keys.txt`.
68-
- For team usage: include multiple recipients in `.sops.yaml` so each developer can decrypt.
139+
140+
- **`sops` can't decrypt**: Ensure the age private key is at `~/.config/sops/age/keys.txt` and the corresponding public key is in `.sops.yaml`.
141+
- **`shred` not found**: The script falls back to `rm -f`. Install `shred` (part of `coreutils` on Linux) for secure deletion.
142+
- **`sops` or `age` not found**: Run `./stackctl.sh doctor` to check prerequisites, or install them manually.

docs/proposals/Secrets Management Proposal.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ Cons:
2525

2626
Concrete steps:
2727
1) Keep only placeholders in `.env.example`. Do not commit real `.env`.
28-
2) Store real values in encrypted files under `secrets/` (SOPS + age). See `docs/secrets.md`.
28+
2) Store real values in encrypted `.env.enc` files per service directory (SOPS + age). See `docs/Managing Secrets.md`.
2929
3) At deploy time, decrypt just-in-time and create Docker secrets, e.g.:
30-
- `sops -d secrets/postgres.dev.env | grep POSTGRES_PASSWORD= | cut -d= -f2 | docker secret create postgres_password -`
30+
- `sops -d postgres/.env.enc | grep POSTGRES_PASSWORD= | cut -d= -f2 | docker secret create postgres_password -`
3131
4) Reference secrets in stacks:
3232
```yaml
3333
services:
@@ -63,7 +63,7 @@ Cons:
6363
Concrete steps:
6464
1) Generate Portainer API key (Settings → API Keys).
6565
2) Write a small script to:
66-
- `sops -d secrets/<svc>.env |` extract values → POST to `/api/endpoints/{id}/docker/secrets/create`.
66+
- `sops -d <service>/.env.enc |` extract values → POST to `/api/endpoints/{id}/docker/secrets/create`.
6767
- Trigger stack redeploy via Portainer Stack API (optional) or `docker stack deploy` locally.
6868
3) Reference secrets in stacks as in Option A.
6969

@@ -86,10 +86,10 @@ Cons:
8686

8787
## Recommended plan (Option A)
8888

89-
Phase 1: Foundation
90-
- Keep `.env.example` placeholders. Done.
91-
- Add `docs/secrets.md` and `.sops.yaml`. Done.
92-
- Create age key(s) and commit encrypted files under `secrets/` (team recipients in `.sops.yaml`).
89+
Phase 1: Foundation
90+
- Keep `.env.example` placeholders. Done.
91+
- Add `.sops.yaml` and `docs/Managing Secrets.md`. ✅ Done — now uses service-local `.env.enc` pattern with `stackctl.sh secrets`.
92+
- Create age key(s) and commit encrypted files per service directory (team recipients in `.sops.yaml`). ✅ Done — see `stackctl.sh secrets encrypt`.
9393

9494
Phase 2: Convert priority services
9595
- Databases: switch to `*_FILE` and define `secrets:` in `stacks/infrastructure.yml`.
@@ -110,13 +110,13 @@ Phase 4: (Optional) Portainer integration
110110
## Open questions
111111
- Which services require secrets and support `*_FILE`? (Postgres yes; Redis no; Mongo users might need env vars or runtime files.)
112112
- Who holds the age private key for CI/CD? (One or more maintainers; store on deployment hosts only.)
113-
- Rotation cadence? (Document per-service rotation procedure in `docs/secrets.md`.)
113+
- Rotation cadence? (Document per-service rotation procedure in `docs/Managing Secrets.md`.)
114114

115115
## Appendix: Example secret extraction
116116
Extract a single key from an encrypted env file without writing plaintext to disk:
117117
```bash
118118
# Create postgres password secret from an encrypted env file
119-
sops -d secrets/postgres.dev.env \
119+
sops -d postgres/.env.enc \
120120
| awk -F= '/^POSTGRES_PASSWORD=/{print $2}' \
121121
| docker secret create postgres_password -
122122
```

0 commit comments

Comments
 (0)