diff --git a/.env.production.example b/.env.production.example index 5adaa39..f39369e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -19,9 +19,13 @@ APP_ENV=production # Server Hostname / Public IP SERVER_PUBLIC_IP=3.25.150.231 +# Enable after this hostname resolves to the server and TCP 443 is allowed. +DOMAIN_NAME=cloudmonitorsite.duckdns.org +COMPOSE_PROFILES=tls + # Production CORS Whitelist # Matches the server's public IP address and optional domain name -CORS_ORIGINS=http://3.25.150.231,http://localhost +CORS_ORIGINS=https://cloudmonitorsite.duckdns.org,http://3.25.150.231,http://localhost # Optional External API Base URL for Frontend build # Leave empty ("") to use same-origin reverse-proxy (/api/* -> backend:8000) diff --git a/Caddyfile b/Caddyfile index f91feb5..4d5dde0 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,28 +1,21 @@ -# CloudMonitor Automated TLS Reverse Proxy Configuration -# Caddy automatically provisions and renews Let's Encrypt certificates -# for the specified DOMAIN_NAME. +# Nginx owns port 80 and performs the public hostname redirect. +{ + auto_https disable_redirects +} {$DOMAIN_NAME:localhost} { - # Security headers + # Renew via TLS-ALPN-01 on the published port 443. + tls { + issuer acme { + disable_http_challenge + } + } header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + Strict-Transport-Security "max-age=86400" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" } - - # Backend API routes - handle /api/* { - reverse_proxy backend:8000 - } - - # Backend Health check - handle /health { - reverse_proxy backend:8000 - } - - # Frontend React SPA - handle { - reverse_proxy frontend:80 - } + # Preserve Nginx API rate limits, /api/health, /prom and lifecycle blocks. + reverse_proxy frontend:80 } diff --git a/docs/HTTPS_SETUP.md b/docs/HTTPS_SETUP.md new file mode 100644 index 0000000..41e86da --- /dev/null +++ b/docs/HTTPS_SETUP.md @@ -0,0 +1,41 @@ +# Production HTTPS + +Public URL: https://cloudmonitorsite.duckdns.org + +Set the DuckDNS A record to the EC2 public IPv4 address (currently 3.25.150.231). +Do not use DuckDNS's automatic browser IP detection: that selects your PC's IP. + +In the server's untracked `.env`, set `DOMAIN_NAME=cloudmonitorsite.duckdns.org` +and `COMPOSE_PROFILES=tls`. Add `https://cloudmonitorsite.duckdns.org` to +`CORS_ORIGINS`, preserving any other required origins. Never overwrite existing +database credentials or JWT secrets with the example file. + +Caddy publishes TCP 443 and obtains/renews a Let's Encrypt certificate using +TLS-ALPN-01. Keep port 443 reachable and the `caddy_data` volume persistent. +Nginx owns port 80 and redirects the DuckDNS hostname to HTTPS. Loopback and +legacy IP requests remain available for existing deployment/agent compatibility. +Use the HTTPS hostname for browser sign-in and new agent configurations. + +All HTTPS routes pass through Nginx, preserving login rate limiting and the +blocked Prometheus lifecycle endpoints. HSTS initially uses a one-day lifetime. +The existing CD workflow reads `COMPOSE_PROFILES` from `.env`, so later deploys +keep Caddy enabled. No paid load balancer or DNS service is required. + +Validation: + +```sh +docker compose -f docker-compose.prod.yml config --quiet +docker compose -f docker-compose.prod.yml run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile +curl -I http://cloudmonitorsite.duckdns.org/ # 308 to HTTPS +curl -fsS https://cloudmonitorsite.duckdns.org/health +curl -fsS https://cloudmonitorsite.duckdns.org/api/health +curl -fsS https://cloudmonitorsite.duckdns.org/prom/api/v1/targets +``` + +If EC2 is stopped and started, its non-Elastic public IP may change: update +DuckDNS before expecting certificate renewal or traffic to work. A reboot does +not change the IP. Changing the public hostname also requires updating the +hostname-specific redirect in `frontend/nginx.conf`. + +For rollback, remove `COMPOSE_PROFILES=tls` from `.env`, revert the HTTPS commit, +and redeploy. Do not delete any Docker volumes. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ed5ac68..699d6c8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,11 +2,27 @@ limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m; limit_req_status 429; +# Caddy sets X-Forwarded-Proto=https. Keep loopback/IP health checks available. +map "$host:$http_x_forwarded_proto" $https_redirect { + default ""; + "cloudmonitorsite.duckdns.org:" "https://cloudmonitorsite.duckdns.org"; + "cloudmonitorsite.duckdns.org:http" "https://cloudmonitorsite.duckdns.org"; +} + +map $http_x_forwarded_proto $forwarded_scheme { + default $scheme; + https https; +} + server { listen 80; listen [::]:80; server_name _; + if ($https_redirect != "") { + return 308 $https_redirect$request_uri; + } + root /usr/share/nginx/html; index index.html; @@ -38,7 +54,7 @@ server { 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_set_header X-Forwarded-Proto $forwarded_scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 60s; proxy_connect_timeout 10s; @@ -53,7 +69,7 @@ server { 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_set_header X-Forwarded-Proto $forwarded_scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 60s; proxy_connect_timeout 10s; @@ -81,7 +97,7 @@ server { 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_set_header X-Forwarded-Proto $forwarded_scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 60s; proxy_connect_timeout 10s;