Files
homelab-optimized/docs/admin/secrets-management.md
Gitea Mirror Bot f02e7fa4d2
Some checks failed
Documentation / Build Docusaurus (push) Failing after 17m1s
Documentation / Deploy to GitHub Pages (push) Has been skipped
Sanitized mirror from private repository - 2026-04-07 10:01:29 UTC
2026-04-07 10:01:30 +00:00

272 lines
10 KiB
Markdown

# Secrets Management Strategy
**Last updated**: March 2026
**Status**: Active policy
This document describes how credentials and secrets are managed across the homelab infrastructure.
---
## Overview
The homelab uses a **layered secrets strategy** with four components:
| Layer | Tool | Purpose |
|-------|------|---------|
| **Source of truth** | Vaultwarden | Store all credentials; accessible via browser + Bitwarden client apps |
| **CI/CD secrets** | Gitea Actions secrets | Credentials needed by workflows (Portainer token, CF token, etc.) |
| **Runtime injection** | Portainer stack env vars | Secrets passed into containers at deploy time without touching compose files |
| **Public mirror protection** | `sanitize.py` | Strips secrets from the private repo before mirroring to `homelab-optimized` |
---
## Vaultwarden — Source of Truth
All credentials **must** be saved in Vaultwarden before being used anywhere else.
- **URL**: `https://vault.vish.gg` (or via Tailscale: `vault.tail.vish.gg`)
- **Collection structure**:
```
Homelab/
├── API Keys/ (OpenAI, Cloudflare, Spotify, etc.)
├── Gitea API Tokens/ (PATs for automation)
├── Gmail App Passwords/
├── Service Passwords/ (per-service DB passwords, admin passwords)
├── SMTP/ (app passwords, SMTP configs)
├── SNMP/ (SNMPv3 auth and priv passwords)
└── Infrastructure/ (Watchtower token, Portainer token, etc.)
```
**Rule**: If a credential isn't in Vaultwarden, it doesn't exist.
---
## Gitea Actions Secrets
For credentials used by CI/CD workflows, store them as Gitea repository secrets at:
`https://git.vish.gg/Vish/homelab/settings/actions/secrets`
### Currently configured secrets
| Secret | Used by | Purpose |
|--------|---------|---------|
| `GIT_TOKEN` | All workflows | Gitea PAT for repo checkout and Portainer git auth |
| `PORTAINER_TOKEN` | `portainer-deploy.yml` | Portainer API token |
| `PORTAINER_URL` | `portainer-deploy.yml` | Portainer base URL |
| `CF_TOKEN` | `portainer-deploy.yml`, `dns-audit.yml` | Cloudflare API token |
| `NPM_EMAIL` | `dns-audit.yml` | Nginx Proxy Manager login email |
| `NPM_PASSWORD` | `dns-audit.yml` | Nginx Proxy Manager password |
| `NTFY_URL` | `portainer-deploy.yml`, `dns-audit.yml` | ntfy notification topic URL |
| `HOMARR_SECRET_KEY` | `portainer-deploy.yml` | Homarr session encryption key |
| `IMMICH_DB_USERNAME` | `portainer-deploy.yml` | Immich database username |
| `IMMICH_DB_PASSWORD` | `portainer-deploy.yml` | Immich database password |
| `IMMICH_DB_DATABASE_NAME` | `portainer-deploy.yml` | Immich database name |
| `IMMICH_JWT_SECRET` | `portainer-deploy.yml` | Immich JWT signing secret |
| `PUBLIC_REPO_TOKEN` | `mirror-to-public.yaml` | PAT for pushing to `homelab-optimized` |
| `RENOVATE_TOKEN` | `renovate.yml` | PAT for Renovate dependency bot |
### Adding a new Gitea secret
```bash
# Via API
TOKEN="your-gitea-pat"
curl -X PUT "https://git.vish.gg/api/v1/repos/Vish/homelab/actions/secrets/MY_SECRET" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"data": "actual-secret-value"}'
```
Or via the Gitea web UI: Repository → Settings → Actions → Secrets → Add Secret.
---
## Portainer Runtime Injection
For secrets needed inside containers at runtime, Portainer injects them as environment variables at deploy time. This keeps credentials out of compose files.
### How it works
1. The compose file uses `${VAR_NAME}` syntax — no hardcoded value
2. `portainer-deploy.yml` defines a `DDNS_STACK_ENV` dict mapping stack names to env var lists
3. On every push to `main`, the workflow calls Portainer's redeploy API with the env vars from Gitea secrets
4. Portainer passes them to the running containers
### Currently injected stacks
| Stack name | Injected vars | Source secret |
|------------|--------------|---------------|
| `dyndns-updater` | `CLOUDFLARE_API_TOKEN` | `CF_TOKEN` |
| `dyndns-updater-stack` | `CLOUDFLARE_API_TOKEN` | `CF_TOKEN` |
| `homarr-stack` | `HOMARR_SECRET_KEY` | `HOMARR_SECRET_KEY` |
| `retro-site` | `GIT_TOKEN` | `GIT_TOKEN` |
| `immich-stack` | `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE_NAME`, `JWT_SECRET`, etc. | `IMMICH_DB_*`, `IMMICH_JWT_SECRET` |
### Adding a new injected stack
1. Add the secret to Gitea (see above)
2. Add it to the workflow env block in `portainer-deploy.yml`:
```yaml
MY_SECRET: ${{ secrets.MY_SECRET }}
```
3. Read it in the Python block:
```python
my_secret = os.environ.get('MY_SECRET', '')
```
4. Add the stack to `DDNS_STACK_ENV`:
```python
'my-stack-name': [{'name': 'MY_VAR', 'value': my_secret}],
```
5. In the compose file, reference it as `${MY_VAR}` — no default value
---
## `.env.example` Pattern for New Services
When adding a new service that needs credentials:
1. **Never** put real values in the compose/stack YAML file
2. Create a `.env.example` alongside the compose file showing the variable names with `REDACTED_*` placeholders:
```env
# Copy to .env and fill in real values (stored in Vaultwarden)
MY_SERVICE_DB_PASSWORD="REDACTED_PASSWORD"
MY_SERVICE_SECRET_KEY=REDACTED_SECRET_KEY
MY_SERVICE_SMTP_PASSWORD="REDACTED_PASSWORD"
```
3. The real `.env` file is blocked by `.gitignore` (`*.env` rule)
4. Reference variables in the compose file: `${MY_SERVICE_DB_PASSWORD}`
5. Either:
- Set the vars in Portainer stack environment (for GitOps stacks), or
- Add to `DDNS_STACK_ENV` in `portainer-deploy.yml` (for auto-injection)
---
## Public Mirror Protection (`sanitize.py`)
The private repo (`homelab`) is mirrored to a public repo (`homelab-optimized`) via the `mirror-to-public.yaml` workflow. Before pushing, `.gitea/sanitize.py` runs to:
1. **Delete** files that contain only secrets (private keys, `.env` files, credential docs)
2. **Delete** the `.gitea/` directory itself (workflows, scripts)
3. **Replace** known secret patterns with `REDACTED_*` placeholders across all text files
### Coverage
`sanitize.py` handles:
- All password/token environment variable patterns (`_PASSWORD=`, `_TOKEN=`, `_KEY=`, etc.)
- Gmail app passwords (16-char and spaced `REDACTED_APP_PASSWORD` formats)
- OpenAI API keys (`sk-*` including newer `sk-proj-*` format)
- Gitea PATs (40-char hex, including when embedded in git clone URLs as `https://<token>@host`)
- Portainer tokens (`ptr_` prefix)
- Cloudflare tokens
- Service-specific secrets (Authentik, Mastodon, Matrix, LiveKit, Invidious, etc.)
- Watchtower token (`REDACTED_WATCHTOWER_TOKEN`)
- Public WAN IP addresses
- Personal email addresses
- Signal phone numbers
### Adding a new pattern to sanitize.py
When you add a new service with a credential that `sanitize.py` doesn't catch, add a pattern to `SENSITIVE_PATTERNS` in `.gitea/sanitize.py`:
```python
# Add to SENSITIVE_PATTERNS list:
(
r'(MY_VAR\s*[:=]\s*)["\']?([A-Za-z0-9_-]{20,})["\']?',
r'\1"REDACTED_MY_VAR"',
"My service credential description",
),
```
**Test the pattern before committing:**
```bash
python3 -c "
import re
line = 'MY_VAR=actual-secret-value'
pattern = r'(MY_VAR\s*[:=]\s*)[\"\']?([A-Za-z0-9_-]{20,})[\"\']?'
print(re.sub(pattern, r'\1\"REDACTED_MY_VAR\"', line))
"
```
### Verifying the public mirror is clean
After any push, check that `sanitize.py` ran successfully:
```bash
# Check the mirror-and-sanitize workflow in Gitea Actions
# It should show "success" for every push to main
https://git.vish.gg/Vish/homelab/actions
```
To manually verify a specific credential isn't in the public mirror:
```bash
git clone https://git.vish.gg/Vish/homelab-optimized.git /tmp/mirror-check
grep -r "sk-proj\|REDACTED_APP_PASSWORD\|REDACTED_WATCHTOWER_TOKEN" /tmp/mirror-check/ || echo "Clean"
rm -rf /tmp/mirror-check
```
---
## detect-secrets
The `validate.yml` CI workflow runs `detect-secrets-hook` on every changed file to prevent new unwhitelisted secrets from being committed.
### Baseline management
If you add a new file with a secret that is intentionally there (e.g., `# pragma: allowlist secret`):
```bash
# Update the baseline to include the new known secret
detect-secrets scan --baseline .secrets.baseline
git add .secrets.baseline
git commit -m "chore: update secrets baseline"
```
If `detect-secrets` flags a false positive in CI:
1. Add `# pragma: allowlist secret` to the end of the offending line, OR
2. Run `detect-secrets scan --baseline .secrets.baseline` locally and commit the updated baseline
### Running a full scan
```bash
pip install detect-secrets
detect-secrets scan > .secrets.baseline.new
# Review diff before replacing:
diff .secrets.baseline .secrets.baseline.new
```
---
## Security Scope
### What this strategy protects
- **Public mirror**: `sanitize.py` ensures no credentials reach the public `homelab-optimized` repo
- **CI/CD**: All workflow credentials are Gitea secrets — never in YAML files
- **New commits**: `detect-secrets` in CI blocks new unwhitelisted secrets
- **Runtime**: Portainer env injection keeps high-value secrets out of compose files
### What this strategy does NOT protect
- **Private repo history**: The private `homelab` repo on `git.vish.gg` contains historical plaintext credentials in compose files. This is accepted risk — the repo is access-controlled and self-hosted. See [Credential Rotation Checklist](credential-rotation-checklist.md) for which credentials should be rotated.
- **Portainer database**: Injected env vars are stored in Portainer's internal DB. Protect Portainer access accordingly.
- **Container environment**: Any process inside a container can read its own env vars. This is inherent to the Docker model.
---
## Checklist for Adding a New Service
- [ ] Credentials saved in Vaultwarden first
- [ ] Compose file uses `${VAR_NAME}` — no hardcoded values
- [ ] `.env.example` created with `REDACTED_*` placeholders if using env_file
- [ ] Either: Portainer stack env vars set manually, OR stack added to `DDNS_STACK_ENV` in `portainer-deploy.yml`
- [ ] If credential pattern is new: add to `sanitize.py` `SENSITIVE_PATTERNS`
- [ ] Run `detect-secrets scan --baseline .secrets.baseline` locally before committing
---
## Related Documentation
- [Credential Rotation Checklist](credential-rotation-checklist.md)
- [Gitea Actions Workflows](../../.gitea/workflows/)
- [Portainer Deploy Workflow](../../.gitea/workflows/portainer-deploy.yml)
- [sanitize.py](../../.gitea/sanitize.py)