Security Hardening Guide
This document describes the security measures implemented for the Geetanjali application infrastructure.
Table of Contents
- Server-Level Security
- Docker Container Security
- Network Security
- Application Security
- Secrets Management (SOPS + age)
- Security Checklist
- Incident Response
Server-Level Security
SSH Hardening
SSH is configured for key-based authentication only:
# /etc/ssh/sshd_config settings
PasswordAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
Best practices:
- Use SSH keys with Ed25519 or RSA 4096-bit
- Disable password authentication
- Use non-root user for regular operations
- Keep root access for system administration only
Firewall (UFW)
UFW is configured to allow only essential ports:
# View current rules
sudo ufw status verbose
# Expected configuration:
# 22/tcp - SSH
# 80/tcp - HTTP (redirects to HTTPS)
# 443/tcp - HTTPS
All other ports are blocked. Docker services communicate internally via Docker network.
Fail2ban
Fail2ban protects against brute-force attacks:
# Configuration: /etc/fail2ban/jail.local
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h
findtime = 10m
Commands:
# Check status
sudo fail2ban-client status sshd
# View banned IPs
sudo fail2ban-client status sshd | grep "Banned IP"
# Unban an IP
sudo fail2ban-client set sshd unbanip <IP>
Audit Logging (auditd)
Auditd provides kernel-level audit logging:
# Check status
sudo systemctl status auditd
# View audit logs
sudo ausearch -ts today
# View login attempts
sudo ausearch -m USER_LOGIN
Docker Container Security
Linux Capabilities
Linux capabilities break down root privileges into ~40 distinct powers. We drop all capabilities except those explicitly needed:
| Capability | Description | Containers Using |
|---|---|---|
| NET_BIND_SERVICE | Bind to ports < 1024 | Frontend (nginx) |
| CHOWN | Change file ownership | Redis, Frontend |
| SETUID | Set user ID | Redis, Frontend |
| SETGID | Set group ID | Redis, Frontend |
Configuration in docker-compose.yml:
services:
redis:
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
Container-Specific Hardening
| Container | Runs As | Capabilities | PID Limit | Extra Hardening |
|---|---|---|---|---|
| postgres | postgres (non-root) | cap_drop: ALL + minimal cap_add | - | no-new-privileges |
| redis | redis (uid 999) | cap_drop: ALL | 64 | read-only FS, tmpfs /data, no-new-privileges |
| chromadb | chromauser (uid 1000) | cap_drop: ALL | 128 | no-new-privileges |
| backend | appuser (uid 1000) | cap_drop: ALL | 256 | no-new-privileges |
| worker | appuser (uid 1000) | cap_drop: ALL | 128 | no-new-privileges, internal-only network |
| frontend | nginx | cap_drop: ALL + NET_BIND_SERVICE, SETUID, SETGID, CHOWN | 64 | no-new-privileges |
| ollama | root (required) | cap_drop: ALL + minimal cap_add | - | no-new-privileges, internal-only |
| prometheus | nobody (uid 65534) | cap_drop: ALL | 64 | no-new-privileges |
| grafana | grafana (uid 472) | cap_drop: ALL | 64 | no-new-privileges |
no-new-privileges
The no-new-privileges security option prevents processes from gaining additional privileges through setuid binaries. This means even if an attacker exploits a vulnerability, they cannot escalate privileges.
Read-Only Filesystems
Redis runs with a read-only root filesystem (read_only: true), with only a tmpfs mount for /tmp. This prevents attackers from writing malicious files even if they gain access.
Docker Log Rotation
Docker is configured to prevent log files from consuming disk space:
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Network Security
Internal Docker Network
All services communicate via an internal Docker bridge network (geetanjali-network). External ports are exposed only for:
- Port 80/443 (frontend nginx)
No external port exposure for:
- PostgreSQL (5432)
- Redis (6379)
- ChromaDB (8000)
- Backend API (8000)
- Worker API (8001) - Prometheus scrapes internally
- Ollama (11434)
- Prometheus (9090)
- Grafana (3000) - Accessed via nginx reverse proxy
Redis Authentication
Redis requires password authentication:
# Connection string format
redis://:${REDIS_PASSWORD}@redis:6379/0
Local Development
For local development, use docker-compose.override.yml to expose ports to localhost only:
services:
postgres:
ports:
- "127.0.0.1:5432:5432"
Important: Never commit docker-compose.override.yml (it’s in .gitignore).
Application Security
Environment Variables
Sensitive values are stored in .env (git-ignored):
POSTGRES_PASSWORDREDIS_PASSWORDJWT_SECRETAPI_KEYANTHROPIC_API_KEYRESEND_API_KEY
API Security
- Rate Limiting: Multi-layer protection
- nginx: 10 req/s for API, 5 req/min for auth endpoints (brute-force protection)
- Backend:
/api/v1/cases/{id}/analyze/asynclimited to 10 requests/hour per IP
- JWT Authentication: User sessions use JWT tokens with configurable expiration
- CORS: Configured for specific allowed origins
- Security Headers: nginx adds X-Frame-Options, X-Content-Type-Options, CSP, etc.
- Metrics Endpoint:
/metricsblocked at nginx level (returns 403)
Cookie Security
In production, enable secure cookies:
COOKIE_SECURE=true # Requires HTTPS
Secrets Management (SOPS + age)
Production secrets are encrypted using SOPS with age encryption. This allows secrets to be safely committed to git while remaining encrypted.
How It Works
.env.enc- Encrypted secrets file (safe to commit to git).sops.yaml- SOPS configuration with public key~/.config/sops/age/keys.txt- Private key (never commit!)
The deploy script automatically decrypts .env.enc to .env on the server during deployment.
Quick Reference
# View decrypted secrets (for debugging)
make secrets-view
# Edit encrypted secrets (opens in vim)
make secrets-edit
# Re-encrypt after editing .env.prod.backup
make secrets-encrypt
Editing Secrets
Option 1: Direct Edit (Recommended)
make secrets-edit
# Opens encrypted file in vim, saves encrypted
Option 2: Manual Workflow
# 1. Decrypt to temporary file
sops --decrypt --input-type dotenv --output-type dotenv .env.enc > .env.tmp
# 2. Edit the file
vim .env.tmp
# 3. Re-encrypt
sops --encrypt --input-type dotenv --output-type dotenv --output .env.enc .env.tmp
# 4. Clean up and commit
rm .env.tmp
git add .env.enc && git commit -m "chore: update secrets"
Key Locations
| Location | Purpose |
|---|---|
Local: ~/.config/sops/age/keys.txt |
Private key for encryption/decryption |
Server: ~/.config/sops/age/keys.txt |
Private key for decryption |
Repo: .env.enc |
Encrypted secrets (safe to commit) |
Repo: .sops.yaml |
SOPS config with public key |
Emergency: Lost Private Key
If the private key is lost, you’ll need to:
- Generate a new key pair:
age-keygen -o ~/.config/sops/age/keys.txt - Update
.sops.yamlwith the new public key - Re-encrypt all secrets from the plaintext backup
- Copy new private key to server
Important: Keep a secure backup of the private key outside of git!
Adding a New Secret
- Run
make secrets-edit - Add the new key=value line
- Save and quit (:wq)
- Commit:
git add .env.enc && git commit -m "chore: add NEW_SECRET" - Deploy:
make deploy
Security Checklist
Pre-Deployment
- Generate secure secrets (32+ random bytes):
python -c "import secrets; print(secrets.token_hex(32))" - Set strong passwords for POSTGRES_PASSWORD, REDIS_PASSWORD
- Set JWT_SECRET and API_KEY to generated values
- Configure ANTHROPIC_API_KEY if using Claude
- Set APP_ENV=production, DEBUG=false
- Set COOKIE_SECURE=true
- Configure CORS_ORIGINS with your domain(s)
Server Setup
- SSH key-based auth only (PasswordAuthentication no)
- UFW enabled with only ports 22, 80, 443
- fail2ban installed and configured
- auditd installed for audit logging
- Docker log rotation configured
- Non-root user for regular operations
Docker Deployment
- No sensitive ports exposed externally
- cap_drop: ALL on containers that don’t need capabilities
- no-new-privileges:true on all containers
- Non-root users in custom Dockerfiles
- .env file not committed to git
- Secrets encrypted with SOPS (.env.enc)
- Private key backed up securely (not in git)
Incident Response
If You Suspect a Breach
- Isolate: Take the affected service offline
docker compose stop <service> - Preserve Evidence: Copy logs before making changes
docker logs geetanjali-<service> > /tmp/<service>-logs.txt 2>&1 - Check Audit Logs:
sudo ausearch -ts <timestamp> sudo journalctl -u docker --since "1 hour ago" - Rotate Credentials: Change all secrets
- Database password
- Redis password
- JWT secret
- API keys
- Review: Check fail2ban logs for attack patterns
sudo fail2ban-client status sshd
Recreating Containers with Fresh State
If data may be compromised:
# Stop and remove container with its volumes
docker compose down <service>
docker volume rm geetanjali_<volume>
# Recreate
docker compose up -d <service>
Updating Security Measures
System Updates
# Update packages (run periodically)
sudo apt update && sudo apt upgrade -y
# Restart services if needed
sudo systemctl restart fail2ban
sudo systemctl restart auditd
Docker Updates
# Rebuild containers with updated base images
docker compose build --no-cache
docker compose up -d
Backup and Restore Procedures
Automated Backups
PostgreSQL backups are automated via cron and stored locally:
# Backup location
/var/backups/geetanjali/
# Backup schedule (configured in server crontab)
# Daily at 2 AM: Full database backup
# Weekly on Sunday: Full backup with 4-week retention
Manual Backup
# Create immediate backup
docker exec geetanjali-postgres pg_dump -U geetanjali geetanjali > backup_$(date +%Y%m%d_%H%M%S).sql
# Backup with compression
docker exec geetanjali-postgres pg_dump -U geetanjali geetanjali | gzip > backup_$(date +%Y%m%d).sql.gz
Restore Procedures
⚠️ Warning: Restoring will overwrite all current data. Always verify the backup file first.
Step 1: Verify Backup File
# Check file size and header
ls -lh backup_file.sql
head -50 backup_file.sql
# For compressed backups
zcat backup_file.sql.gz | head -50
Step 2: Stop Application Services
# Stop services that write to the database
docker compose stop backend worker
Step 3: Restore Database
# Drop and recreate database
docker exec -it geetanjali-postgres psql -U geetanjali -c "DROP DATABASE IF EXISTS geetanjali;"
docker exec -it geetanjali-postgres psql -U geetanjali -c "CREATE DATABASE geetanjali;"
# Restore from backup
cat backup_file.sql | docker exec -i geetanjali-postgres psql -U geetanjali geetanjali
# For compressed backups
zcat backup_file.sql.gz | docker exec -i geetanjali-postgres psql -U geetanjali geetanjali
Step 4: Restart Services
docker compose up -d backend worker
Step 5: Verify Restore
# Check database health
docker exec geetanjali-backend curl -s http://localhost:8000/health
# Verify record counts
docker exec geetanjali-postgres psql -U geetanjali -c "SELECT COUNT(*) FROM cases;"
docker exec geetanjali-postgres psql -U geetanjali -c "SELECT COUNT(*) FROM users;"
Vector Store (ChromaDB) Backup
ChromaDB data is stored in a Docker volume and persists automatically:
# Backup ChromaDB volume
docker run --rm -v geetanjali_chroma_data:/data -v $(pwd):/backup alpine tar czf /backup/chroma_backup.tar.gz /data
# Restore ChromaDB volume
docker compose stop chromadb
docker run --rm -v geetanjali_chroma_data:/data -v $(pwd):/backup alpine sh -c "rm -rf /data/* && tar xzf /backup/chroma_backup.tar.gz -C /"
docker compose up -d chromadb
Backup Checklist
- Verify daily backup cron is running:
crontab -l - Check backup directory space:
df -h /var/backups - Test restore procedure quarterly (on staging)
- Keep offsite backup copy (planned enhancement)