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

View File

@@ -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://<server-ip>/api` |
| Postgres | `<server-ip>: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://<server-ip>` in a browser
1. Open `https://YOUR_HOSTNAME` (or `http://<server-ip>`) 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