diff --git a/docker-compose.ssl.yml b/docker-compose.ssl.yml new file mode 100644 index 0000000..da65f1f --- /dev/null +++ b/docker-compose.ssl.yml @@ -0,0 +1,28 @@ +# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d +# +# This adds port 443, certbot volumes, and a certbot renewal service +# to the base docker-compose.yml configuration. + +services: + nginx: + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro + - certbot_www:/var/www/certbot:ro + - certbot_conf:/etc/letsencrypt:ro + + certbot: + image: certbot/certbot:latest + volumes: + - certbot_www:/var/www/certbot + - certbot_conf:/etc/letsencrypt + networks: + - hoanet + # Auto-renew: check twice daily, only renews if < 30 days remain + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'" + +volumes: + certbot_www: + certbot_conf: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f296caf..56bffd4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -9,11 +9,12 @@ 1. [Prerequisites](#prerequisites) 2. [Deploy to a Fresh Docker Server](#deploy-to-a-fresh-docker-server) -3. [Backup the Local Test Database](#backup-the-local-test-database) -4. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment) -5. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment) -6. [Verifying the Deployment](#verifying-the-deployment) -7. [Environment Variable Reference](#environment-variable-reference) +3. [SSL with Certbot (Let's Encrypt)](#ssl-with-certbot-lets-encrypt) +4. [Backup the Local Test Database](#backup-the-local-test-database) +5. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment) +6. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment) +7. [Verifying the Deployment](#verifying-the-deployment) +8. [Environment Variable Reference](#environment-variable-reference) --- @@ -31,6 +32,11 @@ On the **target server**, ensure the following are installed: The app runs five containers — nginx, backend (NestJS), frontend (Vite/React), PostgreSQL 15, and Redis 7. Total memory footprint is roughly **1–2 GB** idle. +For SSL, the server must also have: +- A **public hostname** with a DNS A record pointing to the server's IP + (e.g., `staging.yourdomain.com → 203.0.113.10`) +- **Ports 80 and 443** open in any firewall / security group + --- ## Deploy to a Fresh Docker Server @@ -129,8 +135,240 @@ This creates: | API | `http:///api` | | Postgres | `:5432` (direct) | -> **Note:** For production, add an SSL-terminating proxy (Caddy, Traefik, or -> an nginx TLS config) in front of port 80. +> At this point the app is running over **plain HTTP**. Continue to the next +> section to enable HTTPS. + +--- + +## SSL with Certbot (Let's Encrypt) + +This section walks through enabling HTTPS using the included nginx container +and a Certbot sidecar. The process has three phases: obtain the certificate, +switch nginx to SSL, and set up auto-renewal. + +### Files involved + +| File | Purpose | +|------|---------| +| `nginx/default.conf` | HTTP-only config (development / initial state) | +| `nginx/certbot-init.conf` | Temporary config used only during initial cert request | +| `nginx/ssl.conf` | Full SSL config (HTTP→HTTPS redirect + TLS termination) | +| `docker-compose.ssl.yml` | Compose override that adds port 443, certbot volumes, and renewal service | + +### Overview + +``` + Phase 1 — Obtain certificate + ┌────────────────────────────────────────────────┐ + │ nginx serves certbot-init.conf on port 80 │ + │ certbot answers ACME challenge → gets cert │ + └────────────────────────────────────────────────┘ + + Phase 2 — Enable SSL + ┌────────────────────────────────────────────────┐ + │ nginx switches to ssl.conf │ + │ port 80 → 301 redirect to 443 │ + │ port 443 → TLS termination → backend/frontend │ + └────────────────────────────────────────────────┘ + + Phase 3 — Auto-renewal + ┌────────────────────────────────────────────────┐ + │ certbot container checks every 12 hours │ + │ renews if cert expires within 30 days │ + │ nginx reloads via cron to pick up new cert │ + └────────────────────────────────────────────────┘ +``` + +--- + +### Phase 1 — Obtain the certificate + +Throughout this section, replace `staging.example.com` with your actual +hostname and `you@example.com` with your real email. + +#### Step 1: Edit `nginx/ssl.conf` with your hostname + +```bash +cd /opt/hoa-ledgeriq + +sed -i 's/staging.example.com/YOUR_HOSTNAME/g' nginx/ssl.conf +``` + +This updates the three places where the hostname appears: the `server_name` +directive and the two certificate paths. + +#### Step 2: Swap in the temporary certbot-init config + +Nginx can't start with `ssl.conf` yet because the certificates don't exist. +Use the minimal HTTP-only config that only serves the ACME challenge: + +```bash +# Back up the current config +cp nginx/default.conf nginx/default.conf.bak + +# Use the certbot init config temporarily +cp nginx/certbot-init.conf nginx/default.conf +``` + +#### Step 3: Start the stack with the SSL compose override + +```bash +docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d --build +``` + +This starts all services plus the certbot container, exposes port 443, +and creates the shared volumes for certificates and ACME challenges. + +#### Step 4: Request the certificate + +```bash +docker compose -f docker-compose.yml -f docker-compose.ssl.yml \ + run --rm certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + --email you@example.com \ + --agree-tos \ + --no-eff-email \ + -d YOUR_HOSTNAME +``` + +You should see output ending with: + +``` +Successfully received certificate. +Certificate is saved at: /etc/letsencrypt/live/YOUR_HOSTNAME/fullchain.pem +Key is saved at: /etc/letsencrypt/live/YOUR_HOSTNAME/privkey.pem +``` + +> **Troubleshooting:** If certbot fails with a connection error, ensure: +> - DNS for `YOUR_HOSTNAME` resolves to this server's public IP +> - Port 80 is open (firewall, security group, cloud provider) +> - No other process is bound to port 80 + +--- + +### Phase 2 — Switch to full SSL + +#### Step 5: Activate the SSL nginx config + +```bash +cp nginx/ssl.conf nginx/default.conf +``` + +#### Step 6: Restart nginx to load the certificate + +```bash +docker compose -f docker-compose.yml -f docker-compose.ssl.yml \ + exec nginx nginx -s reload +``` + +#### Step 7: Verify HTTPS + +```bash +# Should follow redirect and return HTML +curl -I https://YOUR_HOSTNAME + +# Should return 301 redirect +curl -I http://YOUR_HOSTNAME +``` + +Expected `http://` response: + +``` +HTTP/1.1 301 Moved Permanently +Location: https://YOUR_HOSTNAME/ +``` + +The app is now accessible at `https://YOUR_HOSTNAME` with a valid +Let's Encrypt certificate. All HTTP requests redirect to HTTPS. + +--- + +### Phase 3 — Auto-renewal + +The `certbot` service defined in `docker-compose.ssl.yml` already runs a +renewal loop (checks every 12 hours, renews if < 30 days remain). + +However, nginx needs to be told to reload when a new certificate is issued. +Add a host-level cron job that reloads nginx daily: + +```bash +# Add to the server's crontab (as root) +crontab -e +``` + +Add this line: + +```cron +0 4 * * * cd /opt/hoa-ledgeriq && docker compose -f docker-compose.yml -f docker-compose.ssl.yml exec -T nginx nginx -s reload > /dev/null 2>&1 +``` + +This reloads nginx at 4 AM daily. Since reload is graceful (no downtime), +this is safe to run even when no new cert was issued. + +> **Alternative:** If you prefer not to use cron, you can add a +> `--deploy-hook` to the certbot command that reloads nginx only when a +> renewal actually happens: +> ```bash +> docker compose -f docker-compose.yml -f docker-compose.ssl.yml \ +> run --rm certbot certonly \ +> --webroot \ +> --webroot-path /var/www/certbot \ +> --email you@example.com \ +> --agree-tos \ +> --no-eff-email \ +> --deploy-hook "nginx -s reload" \ +> -d YOUR_HOSTNAME +> ``` + +--- + +### Day-to-day usage with SSL + +Once SSL is set up, **always** use both compose files when running commands: + +```bash +# Start / restart +docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d + +# Rebuild after code changes +docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d --build + +# View logs +docker compose -f docker-compose.yml -f docker-compose.ssl.yml logs -f nginx + +# Shortcut: create an alias +echo 'alias dc="docker compose -f docker-compose.yml -f docker-compose.ssl.yml"' >> ~/.bashrc +source ~/.bashrc +dc up -d --build # much easier +``` + +### Reverting to HTTP-only (development) + +If you need to go back to plain HTTP (e.g., local development): + +```bash +# Restore the original HTTP-only config +cp nginx/default.conf.bak nginx/default.conf + +# Run without the SSL override +docker compose up -d +``` + +--- + +### Quick reference: SSL file map + +``` +nginx/ +├── default.conf ← active config (copy ssl.conf here for HTTPS) +├── default.conf.bak ← backup of original HTTP-only config +├── certbot-init.conf ← temporary, used only during initial cert request +└── ssl.conf ← full SSL config (edit hostname before using) + +docker-compose.yml ← base stack (HTTP only, ports 80) +docker-compose.ssl.yml ← SSL overlay (adds 443, certbot, volumes) +``` --- @@ -301,7 +539,7 @@ docker compose exec -T postgres psql \ ### Quick health checks ```bash -# Backend is responding +# Backend is responding (use https:// if SSL is enabled) curl -s http://localhost/api/auth/login | head -c 100 # Database is accessible @@ -314,19 +552,31 @@ docker compose exec -T redis redis-cli ping ### Full smoke test -1. Open `http://` in a browser +1. Open `https://YOUR_HOSTNAME` (or `http://`) in a browser 2. Log in with a known account 3. Navigate to Dashboard — verify health scores load 4. Navigate to Capital Planning — verify Kanban columns render 5. Navigate to Projects — verify project list loads 6. Check the Settings page — version should read **2026.3.2 (beta)** +### Verify SSL (if enabled) + +```bash +# Check certificate details +echo | openssl s_client -connect YOUR_HOSTNAME:443 -servername YOUR_HOSTNAME 2>/dev/null \ + | openssl x509 -noout -subject -issuer -dates + +# Check that HTTP redirects to HTTPS +curl -sI http://YOUR_HOSTNAME | grep -E 'HTTP|Location' +``` + ### View logs ```bash docker compose logs -f # all services docker compose logs -f backend # backend only docker compose logs -f postgres # database only +docker compose logs -f nginx # nginx access/error log ``` --- @@ -352,21 +602,21 @@ docker compose logs -f postgres # database only ## Architecture Overview ``` - ┌─────────────┐ - Browser ────────► │ nginx :80 │ - └──────┬──────┘ - ┌────────┴────────┐ - ▼ ▼ - ┌──────────────┐ ┌──────────────┐ - │ backend :3000│ │frontend :5173│ - │ (NestJS) │ │ (Vite/React) │ - └──────┬───────┘ └──────────────┘ + ┌──────────────────┐ + Browser ─────────► │ nginx :80/:443 │ + └────────┬─────────┘ + ┌──────────┴──────────┐ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ backend :3000│ │frontend :5173│ + │ (NestJS) │ │ (Vite/React) │ + └──────┬───────┘ └──────────────┘ ┌────┴────┐ ▼ ▼ - ┌────────────┐ ┌───────────┐ - │postgres:5432│ │redis :6379│ - │ (PG 15) │ │ (Redis 7) │ - └────────────┘ └───────────┘ + ┌────────────┐ ┌───────────┐ ┌───────────┐ + │postgres:5432│ │redis :6379│ │ certbot │ + │ (PG 15) │ │ (Redis 7) │ │ (renewal) │ + └────────────┘ └───────────┘ └───────────┘ ``` **Multi-tenant isolation:** Each HOA organization gets its own PostgreSQL diff --git a/nginx/certbot-init.conf b/nginx/certbot-init.conf new file mode 100644 index 0000000..acadb54 --- /dev/null +++ b/nginx/certbot-init.conf @@ -0,0 +1,18 @@ +# Temporary nginx config — used ONLY during the initial certbot certificate +# request. Once the cert is obtained, switch to ssl.conf and restart nginx. + +server { + listen 80; + server_name _; + + # Certbot ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Return 503 for everything else so it's obvious this is not the real app + location / { + return 503 "SSL certificate is being provisioned. Try again in a minute.\n"; + add_header Content-Type text/plain; + } +} diff --git a/nginx/ssl.conf b/nginx/ssl.conf new file mode 100644 index 0000000..bc398f7 --- /dev/null +++ b/nginx/ssl.conf @@ -0,0 +1,106 @@ +upstream backend { + server backend:3000; +} + +upstream frontend { + server frontend:5173; +} + +# Redirect all HTTP to HTTPS +server { + listen 80; + server_name _; + + # Let certbot answer ACME challenges over HTTP + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Everything else -> HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS server +server { + listen 443 ssl; + # Replace with your actual hostname: + server_name staging.example.com; + + # --- TLS certificates (managed by certbot) --- + ssl_certificate /etc/letsencrypt/live/staging.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/staging.example.com/privkey.pem; + + # --- Modern TLS settings --- + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # --- Security headers --- + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + # --- Proxy routes (same as default.conf) --- + + # API requests -> NestJS backend + location /api/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + } + + # AI recommendation endpoint needs a longer timeout (up to 3 minutes) + location /api/investment-planning/recommendations { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 180s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + } + + # AI health-score endpoint also needs a longer timeout + location /api/health-scores/calculate { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 180s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + } + + # Everything else -> Vite dev server (frontend) + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } +}