Self-Hosted Docker Stack: What Works, What Doesn't
The entire platform runs on a single $12/month VPS. Four Docker containers, one Nginx config, one PostgreSQL database, one Redis instance. Here's exactly how it's set up — and what I'd change.
Docker Compose
services:
backend:
build:
context: ./fastapi_backend
dockerfile: Dockerfile
env_file: .env.prod
volumes:
- uploads_data:/app/uploads
depends_on:
- db
- redis
restart: unless-stopped
frontend:
build:
context: ./blog_frontend
dockerfile: Dockerfile
env_file: .env.prod
restart: unless-stopped
depends_on:
- backend
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: blog
POSTGRES_PASSWORD: ${DB_PASSWORD}
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
command: redis-server --appendonly yes
restart: unless-stopped
volumes:
postgres_data:
redis_data:
uploads_data:Four services. No Kubernetes. No service mesh. No sidecars. The entire stack starts with docker compose up -d and fits in 2GB of RAM (PostgreSQL ~300MB, Redis ~50MB, backend ~400MB, frontend ~800MB).
Nginx Configuration
Nginx runs on the host (not in Docker) as the single entry point:
upstream frontend {
server 127.0.0.1:3000;
}
upstream backend {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name madhudadi.in;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name madhudadi.in;
ssl_certificate /etc/letsencrypt/live/madhudadi.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/madhudadi.in/privkey.pem;
# Frontend
location /blog {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache static assets
location /blog/_next/static {
proxy_pass http://frontend;
expires 365d;
add_header Cache-Control "public, immutable";
}
}
# API
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Uploads
location /uploads/ {
proxy_pass http://backend;
expires 7d;
add_header Cache-Control "public, immutable";
}
}Key choices:
- Nginx on host, not in Docker — if Docker goes down, Nginx can still serve a maintenance page
- LetsEncrypt for TLS — automatic renewal via certbot cron job
- Static asset caching — Next.js hashed filenames enable 1-year cache headers
- No rate limiting in Nginx — handled by the backend's rate limiters for more granular control
PostgreSQL Tuning
The default PostgreSQL configuration is tuned for a workstation, not a VPS with 2GB RAM. These are the changes that mattered:
# postgresql.conf
max_connections = 40
shared_buffers = 512MB
effective_cache_size = 1GB
work_mem = 32MB
maintenance_work_mem = 64MB
wal_buffers = 16MB
random_page_cost = 1.1
effective_io_concurrency = 200
max_worker_processes = 4
max_parallel_workers = 4
max_parallel_workers_per_gather = 2The most impactful change was random_page_cost = 1.1 (default is 4.0). This tells PostgreSQL that random I/O is fast (SSD-backed VPS), which makes it prefer index scans over sequential scans. Query planning time dropped by ~40% after this change.
Redis Configuration
Redis runs in append-only mode for durability (restart-safe caching):
appendonly yes
appendfsync everysec
maxmemory 256mb
maxmemory-policy allkeys-lruThe maxmemory-policy allkeys-lru ensures old cache entries are evicted when memory runs low. In practice, the Redis instance stays at ~80MB with 50K+ keys.
Backup Strategy
#!/bin/bash
# Weekly backup script
BACKUP_DIR="/backups"
DB_NAME="blog"
# PostgreSQL dump (compressed)
pg_dump -Fc $DB_NAME > "$BACKUP_DIR/db_$(date +%Y%m%d).dump"
# Uploads
tar -czf "$BACKUP_DIR/uploads_$(date +%Y%m%d).tar.gz" /app/uploads
# Keep last 4 weeks
find $BACKUP_DIR -name "*.dump" -mtime +28 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +28 -delete
# Upload to S3-compatible storage
rclone copy $BACKUP_DIR remote:blog-backups/The backup runs weekly via cron and uploads to Backblaze B2 ($0.006/GB/month). Restoring from backup takes about 15 minutes.
Deployment Pipeline
Deployment is a single script:
#!/bin/bash
# deploy.sh
git pull origin main
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
docker system prune -fThe frontend build takes ~3 minutes. The backend build takes ~30 seconds. Total downtime: ~10 seconds (Nginx buffers requests while containers restart).
A health check runs after deploy:
sleep 5
curl -f http://localhost:8000/health || {
echo "Backend health check failed, rolling back..."
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
}What Doesn't Work
Three things I'd fix:
1. No blue-green deployment. The current deploy has ~10 seconds of downtime. For a blog, this is acceptable. But for a production API, I'd add a second frontend container and use Nginx upstream weighting for zero-downtime deploys.
2. No log aggregation. Logs go to stdout/stderr via Docker. Debugging production issues means docker compose logs backend | grep error. A proper setup would use Loki + Grafana or at least send logs to a central file.
3. No CDN for uploads. Images are served directly from the VPS. For a $12 server with 1TB bandwidth, this is fine up to ~10K monthly visitors. Beyond that, I'd move uploads to Cloudflare R2 or an S3 bucket with CloudFront.
Monitoring
# Simple monitoring script (runs every 5 minutes via cron)
#!/bin/bash
check() {
curl -sf http://localhost:8000/health > /dev/null || {
echo "Health check failed at $(date)" >> /var/log/health.log
docker compose restart backend
}
df -h / | awk 'NR==2 {if ($5+0 > 80) print "Disk usage warning: " $5}' >> /var/log/health.log
free -m | awk 'NR==2 {if ($7 < 200) print "Memory warning: " $7 "MB free"}' >> /var/log/health.log
}Simple, effective, zero dependencies. If the health check fails, restart the container. If disk or memory is critical, log a warning.
What's Next
The next post covers SEO that actually works for developer blogs — hreflang strategy, OG image generation, sitemap structure, structured data validator results, and PageSpeed optimizations.
Built with Docker Compose, Nginx, PostgreSQL 16, Redis 7, and zero cloud vendor lock-in.
