Core Stack Decisions
- Reverse proxy — Nginx (raw control) or Caddy (auto-HTTPS, easier config)
- Runtime — Docker + Compose for isolation; bare-metal for low-latency or single-binary apps
- DNS — Cloudflare (proxy + DDoS) or authoritative-only (less latency, no WAF)
- Secrets —
.env files never committed; use docker secret or Infisical for production
- Storage — named Docker volumes for data; bind mounts only for config you edit manually
- OS — Ubuntu 24 LTS (widest support) or Debian 12 (minimal footprint)
Server Initial Setup
# Update, create non-root user, harden SSH
apt update && apt upgrade -y
adduser deploy && usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh && cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
# Disable root + password login
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd
# Firewall
ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable
Nginx Reverse Proxy
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
| Directive | Purpose |
|---|
proxy_buffering off | Required for SSE / streaming responses |
client_max_body_size 50M | Raise for file upload endpoints |
proxy_read_timeout 120 | Raise for long-running LLM calls |
Docker Compose Essentials
services:
app:
image: myapp:latest
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3000:3000" # Bind to loopback only
volumes:
- app_data:/app/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
retries: 5
volumes:
app_data:
pg_data:
- Always bind ports to
127.0.0.1 — never expose DB to 0.0.0.0
restart: unless-stopped survives reboots but respects manual docker stop
SSL — Certbot (Let's Encrypt)
apt install certbot python3-certbot-nginx -y
# Issue cert + auto-configure Nginx
certbot --nginx -d app.example.com
# Dry-run renewal (run before cron)
certbot renew --dry-run
# Cron auto-renewal (runs twice daily)
echo "0 0,12 * * * root certbot renew --quiet" >> /etc/crontab
| Option | When to use |
|---|
--nginx | Auto-patches Nginx config |
--standalone | No web server running yet |
--dns-cloudflare | Wildcard certs (*.domain.com) |
--webroot -w /var/www/html | Behind existing proxy |
DNS + Cloudflare Setup
| Record | Type | Value | Proxy |
|---|
@ | A | VPS IP | Orange (CDN) |
www | CNAME | @ | Orange |
api | A | VPS IP | Orange |
db | A | VPS IP | Grey (DNS only) |
- Turn proxy OFF for direct TCP (DB, SSH, mail)
- Use Full (Strict) SSL mode — not Flexible (MITM risk)
- Add
X-Forwarded-For to Nginx real_ip_header or logs show Cloudflare IPs
- Set
Always Use HTTPS + HSTS in Cloudflare SSL/TLS settings
Systemd Service (non-Docker)
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
User=deploy
WorkingDirectory=/home/deploy/myapp
EnvironmentFile=/home/deploy/myapp/.env
ExecStart=/usr/bin/node dist/server.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now myapp
journalctl -u myapp -f # tail logs
Backup Strategy
# Postgres dump (run via cron or pre-deploy hook)
docker exec postgres pg_dump -U $DB_USER $DB_NAME | \
gzip > /backups/db_$(date +%Y%m%d_%H%M).sql.gz
# Docker volume backup
docker run --rm \
-v myapp_pg_data:/data \
-v /backups:/out \
alpine tar czf /out/vol_$(date +%Y%m%d).tar.gz /data
# Prune backups older than 7 days
find /backups -mtime +7 -delete
- 3-2-1 rule: 3 copies, 2 media, 1 offsite (Backblaze B2 / S3)
- Test restores quarterly — untested backups are not backups
Monitoring Quickref
| Tool | What it covers | Self-host |
|---|
htop / btop | CPU, RAM, processes | Built-in |
| Uptime Kuma | HTTP/TCP uptime + alerts | Docker |
| Netdata | Real-time system metrics | Docker |
| Loki + Grafana | Log aggregation + dashboards | Compose stack |
| Fail2ban | Auto-ban brute-force IPs | apt install fail2ban |
# Quick server health snapshot
free -h && df -h && docker stats --no-stream
Security Hardening Checklist