Add SSL/TLS support with Certbot and update deployment guide

- nginx/ssl.conf: full HTTPS config with HTTP→HTTPS redirect, modern TLS
  settings, HSTS header, and ACME challenge passthrough for renewals
- nginx/certbot-init.conf: minimal HTTP config for initial cert provisioning
- docker-compose.ssl.yml: compose override adding port 443, certbot volumes,
  and auto-renewal sidecar container
- docs/DEPLOYMENT.md: comprehensive 3-phase SSL walkthrough (obtain cert,
  enable SSL, auto-renewal) with day-to-day usage and revert instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 14:28:01 -05:00
parent a550a8d0be
commit 42767e3119
4 changed files with 424 additions and 22 deletions

28
docker-compose.ssl.yml Normal file
View File

@@ -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:

View File

@@ -9,11 +9,12 @@
1. [Prerequisites](#prerequisites) 1. [Prerequisites](#prerequisites)
2. [Deploy to a Fresh Docker Server](#deploy-to-a-fresh-docker-server) 2. [Deploy to a Fresh Docker Server](#deploy-to-a-fresh-docker-server)
3. [Backup the Local Test Database](#backup-the-local-test-database) 3. [SSL with Certbot (Let's Encrypt)](#ssl-with-certbot-lets-encrypt)
4. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment) 4. [Backup the Local Test Database](#backup-the-local-test-database)
5. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment) 5. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment)
6. [Verifying the Deployment](#verifying-the-deployment) 6. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment)
7. [Environment Variable Reference](#environment-variable-reference) 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), The app runs five containers — nginx, backend (NestJS), frontend (Vite/React),
PostgreSQL 15, and Redis 7. Total memory footprint is roughly **12 GB** idle. PostgreSQL 15, and Redis 7. Total memory footprint is roughly **12 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 ## Deploy to a Fresh Docker Server
@@ -129,8 +135,240 @@ This creates:
| API | `http://<server-ip>/api` | | API | `http://<server-ip>/api` |
| Postgres | `<server-ip>:5432` (direct) | | Postgres | `<server-ip>:5432` (direct) |
> **Note:** For production, add an SSL-terminating proxy (Caddy, Traefik, or > At this point the app is running over **plain HTTP**. Continue to the next
> an nginx TLS config) in front of port 80. > 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 ### Quick health checks
```bash ```bash
# Backend is responding # Backend is responding (use https:// if SSL is enabled)
curl -s http://localhost/api/auth/login | head -c 100 curl -s http://localhost/api/auth/login | head -c 100
# Database is accessible # Database is accessible
@@ -314,19 +552,31 @@ docker compose exec -T redis redis-cli ping
### Full smoke test ### Full smoke test
1. Open `http://<server-ip>` in a browser 1. Open `https://YOUR_HOSTNAME` (or `http://<server-ip>`) in a browser
2. Log in with a known account 2. Log in with a known account
3. Navigate to Dashboard verify health scores load 3. Navigate to Dashboard verify health scores load
4. Navigate to Capital Planning verify Kanban columns render 4. Navigate to Capital Planning verify Kanban columns render
5. Navigate to Projects verify project list loads 5. Navigate to Projects verify project list loads
6. Check the Settings page version should read **2026.3.2 (beta)** 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 ### View logs
```bash ```bash
docker compose logs -f # all services docker compose logs -f # all services
docker compose logs -f backend # backend only docker compose logs -f backend # backend only
docker compose logs -f postgres # database only docker compose logs -f postgres # database only
docker compose logs -f nginx # nginx access/error log
``` ```
--- ---
@@ -352,10 +602,10 @@ docker compose logs -f postgres # database only
## Architecture Overview ## Architecture Overview
``` ```
─────────────┐ ┌──────────────────┐
Browser ────────► │ nginx :80 │ Browser ────────► │ nginx :80/:443
──────┬──────┘ └────────┬─────────
────────┴────────┐ ┌──────────┴──────────
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ backend :3000│ │frontend :5173│ │ backend :3000│ │frontend :5173│
@@ -363,10 +613,10 @@ docker compose logs -f postgres # database only
└──────┬───────┘ └──────────────┘ └──────┬───────┘ └──────────────┘
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
┌────────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌───────────┐
│postgres:5432│ │redis :6379│ │postgres:5432│ │redis :6379│ │ certbot │
│ (PG 15) │ │ (Redis 7) │ (PG 15) │ │ (Redis 7) │ │ (renewal) │
└────────────┘ └───────────┘ └────────────┘ └───────────┘ └───────────┘
``` ```
**Multi-tenant isolation:** Each HOA organization gets its own PostgreSQL **Multi-tenant isolation:** Each HOA organization gets its own PostgreSQL

18
nginx/certbot-init.conf Normal file
View File

@@ -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;
}
}

106
nginx/ssl.conf Normal file
View File

@@ -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;
}
}