10 KiB
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
# 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
- The compose file uses
${VAR_NAME}syntax — no hardcoded value portainer-deploy.ymldefines aDDNS_STACK_ENVdict mapping stack names to env var lists- On every push to
main, the workflow calls Portainer's redeploy API with the env vars from Gitea secrets - 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
- Add the secret to Gitea (see above)
- Add it to the workflow env block in
portainer-deploy.yml:MY_SECRET: ${{ secrets.MY_SECRET }} - Read it in the Python block:
my_secret = os.environ.get('MY_SECRET', '') - Add the stack to
DDNS_STACK_ENV:'my-stack-name': [{'name': 'MY_VAR', 'value': my_secret}], - 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:
- Never put real values in the compose/stack YAML file
- Create a
.env.examplealongside the compose file showing the variable names withREDACTED_*placeholders:# 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" - The real
.envfile is blocked by.gitignore(*.envrule) - Reference variables in the compose file:
${MY_SERVICE_DB_PASSWORD} - Either:
- Set the vars in Portainer stack environment (for GitOps stacks), or
- Add to
DDNS_STACK_ENVinportainer-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:
- Delete files that contain only secrets (private keys,
.envfiles, credential docs) - Delete the
.gitea/directory itself (workflows, scripts) - 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_PASSWORDformats) - OpenAI API keys (
sk-*including newersk-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:
# 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:
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:
# 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:
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):
# 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:
- Add
# pragma: allowlist secretto the end of the offending line, OR - Run
detect-secrets scan --baseline .secrets.baselinelocally and commit the updated baseline
Running a full scan
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.pyensures no credentials reach the publichomelab-optimizedrepo - CI/CD: All workflow credentials are Gitea secrets — never in YAML files
- New commits:
detect-secretsin 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
homelabrepo ongit.vish.ggcontains historical plaintext credentials in compose files. This is accepted risk — the repo is access-controlled and self-hosted. See Credential Rotation Checklist 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.examplecreated withREDACTED_*placeholders if using env_file- Either: Portainer stack env vars set manually, OR stack added to
DDNS_STACK_ENVinportainer-deploy.yml - If credential pattern is new: add to
sanitize.pySENSITIVE_PATTERNS - Run
detect-secrets scan --baseline .secrets.baselinelocally before committing