# Deployment Guide ## Requirements - Docker 24.0+ - Docker Compose 2.20+ - 1GB RAM minimum (2GB recommended) - 10GB disk space - TLS certificate (Let's Encrypt or custom) ## Quick Start ```bash # Clone repository git clone https://github.com/your-org/cairnquire.git cd cairnquire # Copy and edit configuration cp .env.example .env # Edit .env with your domain, email, Postmark API key # Start services docker-compose up -d # Verify health curl https://your-domain.com/health ``` ## Configuration ### Environment Variables | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `DOMAIN` | Yes | — | Public domain name | | `DB_PATH` | No | `/data/db.sqlite` | Database file path | | `FILESTORE_PATH` | No | `/data/files` | Content-addressed file storage | | `NOTES_PATH` | No | `/notes` | Watched markdown directory | | `POSTMARK_API_KEY` | Yes | — | Postmark server API token | | `POSTMARK_FROM` | No | `notifications@DOMAIN` | From email address | | `SESSION_SECRET` | Yes | — | 32-byte hex for session signing | | `ENV` | No | `production` | `development` or `production` | ### Docker Compose ```yaml version: "3.8" services: app: build: . ports: - "8080:8080" volumes: - ./data:/data - ./notes:/notes:ro environment: - DOMAIN=${DOMAIN} - DB_PATH=/data/db.sqlite - FILESTORE_PATH=/data/files - NOTES_PATH=/notes - POSTMARK_API_KEY=${POSTMARK_API_KEY} - SESSION_SECRET=${SESSION_SECRET} env_file: - .env restart: unless-stopped healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] interval: 30s timeout: 3s retries: 3 start_period: 10s security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp:noexec,nosuid,size=100m # Optional: Traefik for auto-HTTPS traefik: image: traefik:v3.0 command: - "--api.insecure=false" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt labels: - "traefik.enable=true" restart: unless-stopped ``` ## Production Checklist ### Pre-Deployment - [ ] Change default `SESSION_SECRET` (generate: `openssl rand -hex 32`) - [ ] Configure `POSTMARK_API_KEY` with valid token - [ ] Set `DOMAIN` to public hostname - [ ] Configure DNS A/AAAA records - [ ] Set up SPF and DKIM for email domain - [ ] Review `.env` file — no default secrets ### Security - [ ] TLS 1.3 enforced (Traefik or reverse proxy) - [ ] HSTS header configured - [ ] Firewall rules: only 80/443 open - [ ] Fail2ban or rate limiting on SSH - [ ] Automatic security updates enabled - [ ] Backup encryption key stored securely ### Monitoring - [ ] Health check endpoint accessible - [ ] Log rotation configured - [ ] Disk space alerting (>80%) - [ ] Memory usage alerting - [ ] Uptime monitoring (external) ## Backup Procedures ### Automated Backup ```bash #!/bin/bash # backup.sh BACKUP_DIR="/backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="$BACKUP_DIR/md-hub-$TIMESTAMP.tar.gz" # Create backup tar czf "$BACKUP_FILE" \ -C /data \ db.sqlite \ files/ # Encrypt (optional) gpg --symmetric --cipher-algo AES256 "$BACKUP_FILE" rm "$BACKUP_FILE" # Keep only last 30 days find "$BACKUP_DIR" -name "md-hub-*.tar.gz.gpg" -mtime +30 -delete ``` **Cron**: `0 2 * * * /opt/cairnquire/backup.sh` ### Restore from Backup ```bash # Stop application docker-compose stop app # Extract backup tar xzf md-hub-20240115_020000.tar.gz -C /data # Restart docker-compose up -d app # Verify curl https://your-domain.com/health ``` ## Upgrade Procedures ### Minor Update (patch version) ```bash docker-compose pull docker-compose up -d ``` ### Major Update 1. Read release notes for breaking changes 2. Create backup: `./backup.sh` 3. Pull new image: `docker-compose pull` 4. Run database migrations (auto on startup) 5. Update configuration if needed 6. Deploy: `docker-compose up -d` 7. Verify health and functionality 8. Rollback if issues: restore from backup ## Troubleshooting ### Application Won't Start ```bash # Check logs docker-compose logs -f app # Verify configuration docker-compose exec app /server -check-config # Test database docker-compose exec app sqlite3 /data/db.sqlite ".tables" ``` ### Sync Not Working ```bash # Check file watcher docker-compose exec app ls -la /notes # Verify WebSocket curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \ -H "Sec-WebSocket-Version: 13" \ https://your-domain.com/ws ``` ### Email Not Sending ```bash # Test Postmark connection curl -X POST \ https://api.postmarkapp.com/server \ -H "X-Postmark-Server-Token: YOUR_TOKEN" \ -H "Accept: application/json" # Check email queue docker-compose exec app sqlite3 /data/db.sqlite \ "SELECT * FROM email_queue WHERE status='failed'" ``` ## Scaling ### Vertical Scaling - Increase container memory/CPU limits - SQLite WAL mode handles concurrent reads well - For heavy write loads, consider libsql replication ### Horizontal Scaling (Advanced) ```yaml # Requires shared storage and load balancer services: app1: # ... same config ... environment: - DB_PATH=/shared/db.sqlite - FILESTORE_PATH=/shared/files app2: # ... same config ... environment: - DB_PATH=/shared/db.sqlite - FILESTORE_PATH=/shared/files nginx: image: nginx:alpine volumes: - ./nginx.conf:/etc/nginx/nginx.conf ports: - "80:80" ``` **Note**: Horizontal scaling requires: - Shared filesystem (NFS, EFS) - libsql replication or external database - Sticky sessions for WebSocket ## Support - **Documentation**: `/docs` route (when authenticated) - **Health Check**: `GET /health` - **Metrics**: `GET /metrics` (Prometheus format) - **Logs**: `docker-compose logs -f app` - **Issues**: https://github.com/your-org/cairnquire/issues