# 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://@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)