272 lines
10 KiB
Markdown
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)
|