Self-Hosted Docker Stack: Setup and Optimization Guide

Jun 16, 2026
15 min read

AI Insights

Powered by GPT-4o-mini

Verified Context: self-hosted-docker-stack-setup-and-optimization-guide
Quick Answer

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.

Quick Summary

Learn how to set up a self-hosted Docker stack with Nginx, PostgreSQL, and Redis, including optimization tips and deployment strategies.

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.

Frequently Asked Questions

What is the cost of the VPS used for the self-hosted Docker stack?
The entire platform runs on a single $12/month VPS.
How many Docker containers are used in the setup?
Four Docker containers are used in the setup.
Why is Nginx run on the host instead of in Docker?
Nginx is run on the host so that if Docker goes down, Nginx can still serve a maintenance page.
What is the purpose of using LetsEncrypt in the setup?
LetsEncrypt is used for TLS, with automatic renewal via a certbot cron job.
What was the most impactful PostgreSQL configuration change?
The most impactful change was setting randompagecost to 1.1, which tells PostgreSQL that random I/O is fast.

Related Work

See how this thinking shows up in shipped systems.