Comprehensive Secrets Management & Security Architecture
Documentation for secure credential handling across local development, CI/CD pipelines, and production deployments.
- Overview
- Security Architecture
- Layer 1: Local Development Secrets
- Layer 2: CI/CD Secrets (GitHub Actions)
- Layer 3: Build-Time Environment Variables
- Setup Instructions
- Security Best Practices
- Troubleshooting
- Security Audit Checklist
- Incident Response
This project follows a defense-in-depth approach to secrets management:
- Zero Secrets in Git: No credentials, API keys, or sensitive data in version control
- Environment Separation: Distinct secrets for dev, staging, and production
- Least Privilege: Each component receives only the secrets it needs
- Automated Injection: Build-time secrets from trusted sources (Git metadata)
- Audit Trail: All secret access is logged and reviewable
Protected Against:
- ✅ Credential exposure via public repository
- ✅ Accidental commits of
.envfiles - ✅ Secrets leakage through CI/CD logs
- ✅ Unauthorized access to deployment credentials
- ✅ Man-in-the-middle attacks (FTPS with TLS)
Out of Scope (Acknowledged Limitations):
⚠️ Compromised developer workstation (physical access)⚠️ GitHub account takeover (2FA required)⚠️ Hetzner webspace breach (shared hosting limitations)
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 1: LOCAL DEVELOPMENT │
│ │
│ Location: External directory (E:/Secrets/jozapf-de/) │
│ Storage: app.env (gitignored) │
│ Access: Docker Compose bind mounts │
│ Purpose: Development credentials, test SMTP, local DB │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ .env (Repository) │ app.env (External) │ │
│ │ ───────────────── │ ────────────────── │ │
│ │ PROJECT_SLUG=jozapf-de │ SMTP_HOST=smtp.test.de │ │
│ │ SECRETS_DIR=E:/Secrets/... │ SMTP_PASSWORD=dev_pwd │ │
│ │ HTTP_PORT=8088 │ DB_PASSWORD=dev_db │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 2: CI/CD PIPELINE │
│ │
│ Location: GitHub Secrets (encrypted at rest) │
│ Access: GitHub Actions workflows only │
│ Purpose: Deployment credentials, API keys │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ GitHub Repository → Settings → Secrets → Actions │ │
│ │ │ │
│ │ FTP_SERVER ████████████████ │ │
│ │ FTP_USERNAME ████████████ │ │
│ │ FTP_PASSWORD ████████████████████ │ │
│ │ FTP_DIR ████████████ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 3: BUILD-TIME METADATA │
│ │
│ Source: Git metadata (tags, commit dates) │
│ Injection: CI/CD environment variables │
│ Purpose: Versioning, timestamps, build info │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Generated at build time: │ │
│ │ │ │
│ │ GIT_TAG=$(git describe --tags) → v2.0.2 │ │
│ │ BUILD_DATE=$(date -u +%Y-%m-%d) → 2024-11-09 │ │
│ │ │ │
│ │ Injected into: public/summary.json │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
E:/
├── Projects/
│ └── jozapf-de/ # ← Repository (Git tracked)
│ ├── .env # ✅ Public config (in Git)
│ ├── .env.example # ✅ Template (in Git)
│ ├── compose.yml # ✅ Uses ${SECRETS_DIR}
│ └── ...
│
└── Secrets/
└── jozapf-de/ # ← NEVER in Git!
├── app.env # ❌ Private secrets
├── .htpasswd # ❌ Dashboard credentials
└── backup/
└── app.env.20241109 # ❌ Versioned backups
Purpose: Non-sensitive configuration, pointers to secrets
Location: /workspace/.env
Git Status: ✅ Committed (safe to share)
# .env - Public configuration (safe to commit)
# Project identification
PROJECT_SLUG=jozapf-de
PROJECT_ROOT_WINDOWS=E:/Projects/jozapf-de
# Secrets location (OUTSIDE repository)
SECRETS_DIR=E:/Secrets/jozapf-de
# Service ports
HTTP_PORT=8088
NEXT_PORT=3000
# Docker build target
DOCKER_TARGET=dev
# PHP configuration
PHP_TAG=8.3-fpm-alpine
XDEBUG_MODE=offPurpose: Sensitive credentials for local development
Location: ${SECRETS_DIR}/app.env (e.g., E:/Secrets/jozapf-de/app.env)
Git Status: ❌ NEVER committed (listed in .gitignore)
# app.env - SECRETS (NEVER commit this file!)
# Location: E:/Secrets/jozapf-de/app.env
# SMTP Configuration (Development)
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=your_mailtrap_user
SMTP_PASSWORD=your_mailtrap_password
SMTP_FROM=dev@jozapf.local
# Database Credentials (Development)
DB_HOST=db
DB_PORT=3306
DB_NAME=jozapf_dev
DB_USER=dev_user
DB_PASSWORD=dev_password_change_me
# Dashboard Authentication (Development)
DASHBOARD_PASSWORD_HASH=$argon2id$v=19$m=65536,t=4,p=1$base64_salt$base64_hash
DASHBOARD_SECRET_KEY=generate_with_openssl_rand_hex_32
# Application Keys (Development)
APP_SECRET=dev_secret_key_change_me
API_KEY=dev_api_key_change_me
# Debugging
APP_DEBUG=true
APP_ENV=development# compose.yml - Secrets mounting
services:
php:
# Load secrets from external file
env_file:
- "${SECRETS_DIR}/app.env"
# Bind-mount for legacy PHP code that reads files directly
volumes:
- type: bind
source: "${SECRETS_DIR}/app.env"
target: /var/www/html/assets/php/app.env
read_only: true
# Project code
- ${PROJECT_ROOT_WINDOWS}:/var/www/html:rw
next-dev:
# Next.js doesn't need app.env - uses build-time injection
environment:
NEXT_TELEMETRY_DISABLED: "1"
volumes:
- .:/app
- next_node_modules:/app/node_modules# 1. Create secrets directory (outside repository)
mkdir -p E:/Secrets/jozapf-de
# 2. Copy template
cp .env.example E:/Secrets/jozapf-de/app.env
# 3. Generate secure passwords
openssl rand -hex 32 # For DASHBOARD_SECRET_KEY
openssl rand -base64 24 # For DB_PASSWORD
# 4. Hash dashboard password
php -r "echo password_hash('your_password', PASSWORD_ARGON2ID);"
# 5. Edit app.env with your credentials
nano E:/Secrets/jozapf-de/app.env
# 6. Secure the file (Windows)
icacls E:\Secrets\jozapf-de\app.env /inheritance:r /grant:r "%USERNAME%:F"
# 6. Secure the file (Linux/WSL)
chmod 600 ~/Secrets/jozapf-de/app.envLocation: Repository → Settings → Secrets and variables → Actions
| Secret Name | Description | How to Obtain | Example |
|---|---|---|---|
FTP_SERVER |
Hetzner FTP hostname | Hetzner KonsoleH → FTP-Zugang | ftp.jozapf.de |
FTP_USERNAME |
FTP username | Hetzner KonsoleH | u123456789 |
FTP_PASSWORD |
FTP password | Hetzner KonsoleH → FTP-Passwort | *** |
FTP_DIR |
Target directory | Server directory structure | /public_html/ |
-
Navigate to GitHub Secrets
GitHub Repository → Settings (tab) → Secrets and variables (left sidebar) → Actions → New repository secret (button) -
Add FTP_SERVER
Name: FTP_SERVER Secret: ftp.jozapf.de → Add secret -
Add FTP_USERNAME
Name: FTP_USERNAME Secret: u123456789 → Add secret -
Add FTP_PASSWORD
Name: FTP_PASSWORD Secret: [paste from Hetzner KonsoleH] → Add secret -
Add FTP_DIR
Name: FTP_DIR Secret: /public_html/ → Add secret
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to Hetzner
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
# Secrets are accessed via ${{ secrets.NAME }}
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
protocol: ftps
local-dir: ./out/
server-dir: ${{ secrets.FTP_DIR }}
# Security: Exclude sensitive files
exclude: |
**/.git*
**/.env*
**/node_modules/**
**/*.logWhen to Rotate:
- ✅ Every 90 days (scheduled)
- ✅ After team member departure
- ✅ On suspected compromise
- ✅ After failed security audit
How to Rotate:
# 1. Generate new FTP password in Hetzner KonsoleH
# 2. Update GitHub Secret
Repository → Settings → Secrets → FTP_PASSWORD → Update
# 3. Test deployment with new credentials
git commit --allow-empty -m "test: verify secret rotation"
git push
# 4. Monitor GitHub Actions for successful deployment
gh run watch
# 5. Document rotation in security log
echo "$(date -u): FTP_PASSWORD rotated" >> SECURITY_LOG.mdPurpose: Inject version and timestamp information from Git metadata into the build
Source: Git tags and commit timestamps
Target: public/summary.json
# .github/workflows/deploy.yml
jobs:
build-deploy:
steps:
- name: Checkout with full history
uses: actions/checkout@v4
with:
fetch-depth: 0 # ← Required for git describe --tags
- name: Extract build metadata
run: |
# Get latest Git tag (e.g., v2.0.2)
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo '')
echo "GIT_TAG=${GIT_TAG}" >> $GITHUB_ENV
# Get current timestamp (ISO 8601)
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
# Log for audit trail
echo "Build metadata: ${GIT_TAG} @ ${BUILD_DATE}"
- name: Build with injected metadata
env:
GIT_TAG: ${{ env.GIT_TAG }}
BUILD_DATE: ${{ env.BUILD_DATE }}
run: |
npm run build
# Verify injection worked
cat public/summary.json | jq '.'// scripts/generate-summary.ts
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
// Fallback functions for local development
function getPkgVersion(): string {
const pkgPath = path.join(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
return pkg.version ?? "0.0.0";
}
function getGitIsoDate(): string {
try {
const iso = execSync('git log -1 --pretty="%cI"')
.toString()
.trim()
.replace(/"/g, "");
return iso;
} catch {
return new Date().toISOString();
}
}
async function main() {
// Priority: CI environment variables → fallbacks
const version =
process.env.GIT_TAG?.replace(/^v/, "") || // CI: from Git tag
process.env.npm_package_version || // npm context
getPkgVersion(); // package.json
const last_updated =
process.env.BUILD_DATE || // CI: from workflow
getGitIsoDate(); // Git log
const summary = {
project: "jozapf.de",
version,
last_updated,
key_points: [
"Static export for shared hosting",
"Automated CI/CD via GitHub Actions",
"Docker-based development workflow",
"TypeScript + Next.js 16",
],
};
// Write to public/ for inclusion in build
const outDir = path.join(process.cwd(), "public");
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, "summary.json");
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n");
console.log(`✅ Generated summary.json: ${version} @ ${last_updated}`);
}
main().catch((err) => {
console.error("❌ Failed to generate summary:", err);
process.exit(1);
});# Local build (uses fallbacks)
npm run build
cat out/summary.json
# Expected output (local):
{
"project": "jozapf.de",
"version": "2.0.2",
"last_updated": "2024-11-09T12:34:56Z",
"key_points": [...]
}
# CI build (uses GIT_TAG and BUILD_DATE)
# Check GitHub Actions logs or deployed site:
curl https://jozapf.de/summary.json | jq .git clone git@github.com:JoZapf/jozapf-de.git
cd jozapf-de# Windows (PowerShell)
New-Item -Path "E:\Secrets\jozapf-de" -ItemType Directory -Force
# Linux/macOS
mkdir -p ~/Secrets/jozapf-de# Copy template
cp .env.example E:/Secrets/jozapf-de/app.env # Windows
cp .env.example ~/Secrets/jozapf-de/app.env # Linux/macOS
# Edit with your credentials
nano ~/Secrets/jozapf-de/app.env# Dashboard secret key (32 bytes hex)
openssl rand -hex 32
# Database password (24 bytes base64)
openssl rand -base64 24
# Hash dashboard password (Argon2id)
php -r "echo password_hash('YourSecurePassword123', PASSWORD_ARGON2ID) . PHP_EOL;"# Check that secrets directory is outside repo
ls -la $(git rev-parse --show-toplevel)/../Secrets/jozapf-de/
# Start development environment
docker compose --profile next up next-dev
# Check that secrets are mounted correctly
docker compose exec php cat /var/www/html/assets/php/app.env1. Log into Hetzner KonsoleH
2. Navigate to: FTP-Zugang
3. Note: Hostname, Username, Password
4. Note: Target directory (usually /public_html/)
1. GitHub → Repository → Settings → Secrets → Actions
2. Add: FTP_SERVER, FTP_USERNAME, FTP_PASSWORD, FTP_DIR
# Create a test commit
git commit --allow-empty -m "test: verify deployment"
git push
# Watch GitHub Actions
gh run watch
# Verify deployment
curl https://jozapf.de/summary.json-
Use Strong Passwords
# Minimum 32 characters, random openssl rand -base64 32 -
Enable 2FA on GitHub
GitHub → Settings → Password and authentication → Two-factor authentication → Enable -
Rotate Secrets Regularly
- Dashboard passwords: Every 90 days - FTP credentials: Every 180 days - API keys: On every team change -
Use Read-Only Mounts
volumes: - type: bind source: "${SECRETS_DIR}/app.env" target: /var/www/html/assets/php/app.env read_only: true # ← Prevents accidental overwrites
-
Audit Secret Access
# Check GitHub Actions logs for secret usage gh run view --log | grep -i "secret" # Check Docker logs for mounted secrets docker compose logs php | grep "app.env"
-
Use FTPS, Not Plain FTP
# Always use protocol: ftps with: protocol: ftps # ← TLS encryption port: 21
-
Never Commit Secrets
# Check before committing git diff --cached | grep -i "password\|secret\|key" # Use git-secrets for automated checks git secrets --scan
-
Never Log Secrets
// ❌ BAD console.log("DB_PASSWORD:", process.env.DB_PASSWORD); // ✅ GOOD console.log("DB connection configured");
-
Never Share Secrets via Chat/Email
❌ Slack: "Here's the FTP password: hunter2" ✅ Use: 1Password shared vault or similar -
Never Use Weak Passwords
# ❌ BAD: Dictionary words, sequential password123, qwerty, admin # ✅ GOOD: Random, 32+ chars openssl rand -base64 32
-
Never Reuse Passwords
Each service needs unique credentials: - Dev SMTP ≠ Prod SMTP - Local DB ≠ Staging DB ≠ Prod DB
Symptoms:
Error: Cannot read app.env: No such file or directory
Diagnosis:
# Check if SECRETS_DIR is set
echo $SECRETS_DIR
# Check if file exists on host
ls -la E:/Secrets/jozapf-de/app.env # Windows
ls -la ~/Secrets/jozapf-de/app.env # Linux
# Check Docker mount
docker compose config | grep app.envSolution:
# 1. Verify .env file has correct SECRETS_DIR
cat .env | grep SECRETS_DIR
# 2. Create missing directory
mkdir -p E:/Secrets/jozapf-de
# 3. Copy template
cp .env.example E:/Secrets/jozapf-de/app.env
# 4. Restart containers
docker compose down
docker compose --profile next up next-devSymptoms:
Error: FTP login failed: 530 Login incorrect
Diagnosis:
# Test FTP credentials locally
ftp ftp.jozapf.de
# Username: [enter FTP_USERNAME]
# Password: [enter FTP_PASSWORD]Solution:
# 1. Verify credentials in Hetzner KonsoleH
# 2. Update GitHub Secret
GitHub → Settings → Secrets → FTP_PASSWORD → Update
# 3. Test with manual workflow dispatch
GitHub → Actions → deploy.yml → Run workflow
# 4. Check logs
gh run view --logSymptoms:
{
"version": "0.0.0-dev",
"last_updated": "2024-01-01T00:00:00Z"
}Diagnosis:
# Check if Git tags exist
git describe --tags
# Check GitHub Actions environment
gh run view --log | grep "GIT_TAG\|BUILD_DATE"Solution:
# 1. Ensure fetch-depth: 0 in workflow
cat .github/workflows/deploy.yml | grep fetch-depth
# 2. Create Git tag if missing
git tag v2.0.2
git push --tags
# 3. Trigger new build
git commit --allow-empty -m "chore: trigger build"
git pushSymptoms:
[deploy] Connecting to ftp.jozapf.de with password: hunter2
Diagnosis:
# Check workflow for echo statements
cat .github/workflows/deploy.yml | grep "echo.*secret"Solution:
# Remove debug statements that print secrets
# ❌ BAD
- run: echo "Password is ${{ secrets.FTP_PASSWORD }}"
# ✅ GOOD
- run: echo "FTP credentials configured"
# GitHub automatically masks secrets, but don't echo them- No
.envfiles with secrets committed to Git - All secrets stored in external directory (
SECRETS_DIR) - GitHub Actions secrets up-to-date
- No secrets logged in CI/CD output
- No secrets in Docker images (use
docker history) -
app.envhas correct file permissions (600) -
.gitignoreincludes all secret paths - Team members have 2FA enabled
- FTP password complexity meets requirements (16+ chars)
- No secrets shared via insecure channels
- Rotate FTP credentials
- Rotate dashboard passwords
- Review GitHub Actions audit log
- Review Docker Compose secret mounts
- Test secret recovery procedure
- Update
.env.exampletemplate - Verify backups of
app.envexist - Check for leaked secrets with
git-secretsortrufflehog - Review this document for updates
- Onboard new team members with security training
# Check for committed secrets (basic)
git log -p | grep -i "password\|secret\|key" | head -20
# Check for committed secrets (advanced)
trufflehog git file://. --only-verified
# Check .gitignore effectiveness
git check-ignore -v E:/Secrets/jozapf-de/app.env
# Verify Docker mount permissions
docker compose exec php ls -la /var/www/html/assets/php/app.env
# Check GitHub Actions recent runs
gh run list --limit 10
# Verify FTPS (not FTP) in workflow
cat .github/workflows/deploy.yml | grep "protocol:"Immediate Actions:
# 1. DO NOT just delete the file and commit
# (History still contains secrets!)
# 2. Remove from history with git-filter-repo
git filter-repo --path .env --invert-paths
git filter-repo --path app.env --invert-paths
# 3. Force push (requires team coordination)
git push origin --force --all
# 4. Rotate ALL exposed secrets immediately
# - Generate new passwords
# - Update GitHub Secrets
# - Update local app.env
# 5. Notify team
echo "SECURITY INCIDENT: Secrets exposed in commit $(git rev-parse HEAD)"Immediate Actions:
# 1. Change FTP password in Hetzner KonsoleH
# 2. Update GitHub Secret immediately
# 3. Review FTP access logs for suspicious activity
# 4. Check deployed files for tampering
# 5. Deploy clean build from known-good commit
# 6. Document incident in SECURITY_LOG.mdImmediate Actions:
# 1. Cancel running workflow
gh run cancel <run-id>
# 2. Revoke GitHub personal access tokens
GitHub → Settings → Developer settings → Tokens
# 3. Enable branch protection
GitHub → Settings → Branches → Add rule
- Require pull request reviews
- Require status checks
# 4. Review Actions audit log
GitHub → Settings → Actions → Logs
# 5. Rotate all GitHub SecretsGitHub Security: https://github.com/security
Hetzner Support: +49 (0)9831 505-0
Project Lead: [your email]
# .env.example - Template for .env (safe to commit)
# Copy to .env and adjust values
PROJECT_SLUG=jozapf-de
PROJECT_ROOT_WINDOWS=E:/Projects/jozapf-de
SECRETS_DIR=E:/Secrets/jozapf-de
HTTP_PORT=8088
NEXT_PORT=3000
DOCKER_TARGET=dev
PHP_TAG=8.3-fpm-alpine
XDEBUG_MODE=off# app.env.example - Template for secrets (NEVER commit actual app.env!)
# Copy to ${SECRETS_DIR}/app.env and fill with real credentials
# SMTP Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASSWORD=CHANGE_ME_generate_with_openssl_rand
SMTP_FROM=noreply@example.com
# Database Credentials
DB_HOST=db
DB_PORT=3306
DB_NAME=example_db
DB_USER=example_user
DB_PASSWORD=CHANGE_ME_generate_with_openssl_rand
# Dashboard Authentication
DASHBOARD_PASSWORD_HASH=CHANGE_ME_hash_with_php_password_hash
DASHBOARD_SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_hex_32
# Application Keys
APP_SECRET=CHANGE_ME_generate_with_openssl_rand
API_KEY=CHANGE_ME_generate_with_openssl_rand
# Environment
APP_DEBUG=true
APP_ENV=development# Secrets and Sensitive Data
.env.local
.env.*.local
app.env
*.env.backup
secrets/
**/secrets/
# Hetzner FTP Credentials
.ftpconfig
.ftppass
# Password Files
.htpasswd
*.pem
*.key
*.crt
# Docker Secrets
docker-compose.override.yml- 2024-11-09: Initial security documentation for Next.js migration
- 2024-11-09: Added three-layer secrets architecture
- 2024-11-09: Documented GitHub Actions secrets setup
- 2024-11-09: Added incident response procedures
This security documentation is part of the jozapf.de project and is licensed under the MIT License.
For security concerns or questions about this documentation:
Jo Zapf
Email: Contact via GitHub
Security Issues: Report via GitHub Security
🔒 Security is not a feature – it's a foundation.
Last Updated: 2024-11-09 | Version: 1.0.0