# Setting Up a Self-Hosted Docker Stack: A Practical Guide URL: https://madhudadi.in/blog/posts/self-hosted-docker-stack-setup-and-optimization-guide Published: 2026-06-16 Tags: Architecture, Production, PostgreSQL Read time: 15 min Difficulty: intermediate > Docker Compose breakdown, Nginx config, Redis caching strategy, PostgreSQL tuning for async workloads, Cloudflare proxying, backup strategy, and honest lessons from running production on a $12 VPS.# 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 ```yaml 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: ```nginx 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: ```ini # 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 = 2 ``` The 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): ```conf appendonly yes appendfsync everysec maxmemory 256mb maxmemory-policy allkeys-lru ``` The `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 ```bash #!/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: ```bash #!/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 -f ``` The 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: ```bash 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 ```bash # 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.*