# Deployment Guide The site runs as a **Node server inside a container** on your homelab Cosmos system, fronted by your reverse proxy (Traefik / Caddy / nginx) with TLS for `gregkilmer.com`. --- ## 1. Configure environment Copy the example env file and fill in what you need: ```bash cp .env.example .env ``` At minimum, set `SITE_URL` (your canonical public URL). To enable contact-form delivery, set **either** SMTP vars **or** `CONTACT_WEBHOOK_URL` (see [Contact delivery](#contact-delivery) below). Everything else is optional. | Variable | Purpose | Default | | ----------------------------------------------------- | ----------------------------------------- | ------------------------ | | `PORT` | Port the Node server binds to | `4321` | | `HOST` | Bind address (`0.0.0.0` for container) | `0.0.0.0` | | `SITE_URL` | Canonical URL for metadata | `https://gregkilmer.com` | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP relay | unset | | `SMTP_FROM` | From address for outgoing mail | derived from `SITE_URL` | | `SMTP_ALLOW_SELFSIGNED` | Accept self-signed cert (internal hop) | `false` | | `CONTACT_TO` | Inbox that receives submissions | `contact@gregkilmer.com` | | `CONTACT_WEBHOOK_URL` | JSON webhook fallback (Discord/Slack/n8n) | unset | | `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` | Per-IP rate limit | `5` / `15` | | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection | unset (skipped) | --- ## 2. Build & run with Docker Compose ```bash docker compose up -d --build ``` This builds the multi-stage image (`Dockerfile`), starts the container, and maps host port `${HOST_PORT:-4321}` to container `4321`. Logs: ```bash docker compose logs -f greg-site ``` Healthcheck hits `/` every 30s. Verify locally: ```bash curl http://localhost:4321/ ``` --- ## 3. Reverse proxy + TLS Point your Cosmos reverse proxy at the container on port `4321`. The two common shapes: ### Option A — Traefik (labels) If your Cosmos uses Traefik, add labels to the service in `docker-compose.yml`: ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.greg-site.rule=Host(`gregkilmer.com`)" - "traefik.http.routers.greg-site.entrypoints=websecure" - "traefik.http.routers.greg-site.tls.certresolver=your-resolver" - "traefik.http.services.greg-site.loadbalancer.server.port=4321" ``` Then drop the `ports:` mapping and join the Traefik network instead. ### Option B — Caddy / nginx Forward `gregkilmer.com` (HTTPS) to `127.0.0.1:4321`. With Caddy, automatic TLS is a one-liner: ```caddyfile gregkilmer.com { reverse_proxy 127.0.0.1:4321 } ``` Either way, ensure the proxy forwards `X-Forwarded-For` / `X-Real-IP` so the contact API's rate limiter sees real client IPs (not the proxy's). --- ## 4. Contact delivery The `/api/contact` endpoint delivers submissions through the first configured path: 1. **SMTP** (if `SMTP_HOST` is set): sends a real email via nodemailer. Works with any SMTP relay (Gmail app-password, Mailgun, SES, your homelab mail server). 2. **Webhook** (if `CONTACT_WEBHOOK_URL` is set and SMTP is not): POSTs a JSON payload `{ name, email, message, text }`. Great for a Discord/Slack/n8n inbound. 3. **Neither configured**: returns `503` with a friendly "email me directly" message so the form never silently swallows input. ### SMTP relay DNS requirements (homelab) When pointing the form at a homelab SMTP relay (Postfix, Mailcow, etc.), the relay itself must have proper DNS records or mail **will** land in recipients' spam folders — this is the relay's responsibility, not the site's. Ensure the sending domain (the host part of `SMTP_FROM`, or the relay's HELO name) has: - **SPF** (`TXT` on the domain): lists the relay's IP as an authorized sender. - **DKIM** (`TXT` on `selector._domainkey`): signs outgoing messages so the relay's identity can be verified. - **DMARC** (`TXT` on `_dmarc`): tells receivers how to handle SPF/DKIM failures (start at `p=none` to monitor, tighten to `quarantine` later). - **rDNS / PTR** on the relay's public IP: must match the relay's HELO name. `SMTP_FROM` should use a domain you control (e.g. `no-reply@gregkilmer.com`), not `gmail.com` — SPF on a domain you don't own will reject or sink the mail. Test delivery + spam score with [mail-tester.com](https://www.mail-tester.com/) (send a form submission to the unique address it gives you). #### Self-signed certificates on an internal relay If the SMTP relay (e.g. Stalwart) is on the same private Docker network as the site container and presents a self-signed TLS certificate, nodemailer's default certificate verification will reject the connection with `Error: self-signed certificate` (`code: ESOCKET`). For a trusted internal hop that never traverses an untrusted network, set: ``` SMTP_ALLOW_SELFSIGNED=true ``` This disables TLS certificate verification for the SMTP connection only. It is `false` by default; only enable it when the relay is on a trusted private network. For a relay reachable over the public internet, prefer installing a real certificate (Let's Encrypt) on the relay instead. ### Cloudflare Turnstile (bot protection) The form includes an invisible Cloudflare Turnstile challenge. To enable it: 1. Create a widget at [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile). Use the **Managed** challenge mode (invisible, no puzzle). 2. Put the **site key** in `TURNSTILE_SITE_KEY` and the **secret key** in `TURNSTILE_SECRET_KEY` (both in `.env`). 3. The site key is public and ships to the browser; the secret stays server-side. The API verifies each token against Cloudflare's `siteverify` endpoint before sending mail. If neither key is set, Turnstile is skipped entirely (useful for local dev — the form works without a challenge). The honeypot + per-IP rate limit still apply regardless. Test it: ```bash # With Turnstile configured, this is rejected (no/bad token) with 400: curl -X POST http://localhost:4321/api/contact \ -H "Content-Type: application/json" \ -d '{"name":"Test","email":"test@example.com","message":"Hello there."}' # Without Turnstile (keys unset), this succeeds and delivers mail: curl -X POST http://localhost:4321/api/contact \ -H "Content-Type: application/json" \ -d '{"name":"Test","email":"test@example.com","message":"Hello there."}' ``` ### Anti-scraping The site applies a layered anti-scraping policy: - **`public/robots.txt`** allows legitimate search engines (Google, Bing) but blocks known AI training bots (GPTBot, ClaudeBot, CCBot, Bytespider, etc.) and disallows `/api/` and `/contact` for everyone. Note: robots.txt is voluntary; determined scrapers ignore it. - **`noindex` on `/contact`**: the contact page emits `` so it won't be indexed even if a bot reaches it. - **Email obfuscation**: the Gmail address is never in the HTML source as plaintext; it's base64-encoded and decoded to a `mailto:` client-side on click. Dumb harvesters that grep for `@` or `mailto:` find nothing. --- ## 5. Security headers & hardening Security is split between the app (version-controlled) and the reverse proxy (owns the TLS edge). This division keeps each part where it's strongest. ### Set in the app — `src/middleware.ts` Every response carries these headers, applied by Astro middleware: | Header | Value | Purpose | | --- | --- | --- | | `Content-Security-Policy` | see file | Restricts script/style/font/img/connect/frame sources | | `X-Content-Type-Options` | `nosniff` | Blocks MIME-sniffing | | `X-Frame-Options` | `DENY` | Clickjacking (legacy browsers; CSP `frame-ancestors 'none'` covers modern ones) | | `Referrer-Policy` | `strict-origin-when-cross-origin` | Limits referrer leakage | | `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), interest-cohort=()` | Disables unused browser features | The CSP allows `'self'`, `'unsafe-inline'` (temporary — see below), and `https://challenges.cloudflare.com` (Cloudflare Turnstile, used only on `/contact`). All fonts, images, and styles are self-hosted, so nothing else needs an external origin. ### Set at the reverse proxy (Nginx Proxy Manager) These belong at the proxy because they only make sense at the TLS edge, or because only the proxy can overwrite client-supplied headers: - **HTTP → HTTPS redirect** (Force SSL / `return 301 https://$host$request_uri`). - **`Strict-Transport-Security`** (HSTS) — ignored over HTTP, so the proxy must set it. Example: `max-age=31536000; includeSubDomains`. Leave `preload` off until you've run the site a while and confirmed all subdomains serve HTTPS. - **`X-Forwarded-For` overwrite** — **critical for the contact API's rate limiter.** Add to the proxy host's Custom Nginx Configuration: ```nginx add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; ``` The `X-Forwarded-For $remote_addr` line **overwrites** (not appends) any client-supplied value, closing a rate-limit bypass where an attacker spoofs a fresh IP per request. (`/api/contact` reads the first XFF hop as the client IP.) ### CSRF — not applicable, intentionally The contact endpoint needs no CSRF token because none of the standard CSRF preconditions hold: - **No cookies or sessions** — there is no auth and no session state to forge against. - **JSON-only body** — the handler calls `request.json()`; a cross-origin form POST (the classic CSRF vector) sends form-encoded data, which fails JSON parsing and is rejected with 400 before any side effect. - **No CORS** — the endpoint has no permissive `Access-Control-Allow-Origin`, so a cross-origin `fetch` with JSON from a malicious site is blocked by the browser's same-origin policy unless the request is "simple" (a JSON POST with `Content-Type: application/json` is not simple and triggers a CORS preflight that fails). If any of these change (auth added, a form-encodable fallback, CORS opened), revisit CSRF here. ### Rate-limiting caveats - The limiter in `/api/contact` is **per-worker** (in-memory). For a single container behind the proxy this is fine. If you ever scale to multiple replicas, swap the in-memory `Map` for Redis/Upstash so the limit is global. - The IP is taken from `X-Forwarded-For`, which is why the proxy XFF overwrite above matters. - The proxy can additionally rate-limit at the edge (`limit_req`) for defense before traffic reaches Node. Optional — the app already limits. ### Dependency hygiene There is no CI pipeline; deploys are manual. Before each deploy, run: ```bash npm audit # report known vulns in the dependency tree npm audit --omit=dev # production deps only npm outdated # check for behind-version packages ``` Address any `high`/`critical` audit findings before building. `npm update` within the caret ranges in `package.json` is safe between full upgrades. ### `/.well-known/security.txt` A [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116) security contact lives at `public/.well-known/security.txt`, served at `https://gregkilmer.com/.well-known/security.txt`. It points to `security@gregkilmer.com` and expires ~1 year out — **renew the `Expires:` date before it passes**, or vulnerability reporters' tools will flag it as stale. ### Future: tighten CSP with nonces (deferred) `script-src` currently includes `'unsafe-inline'` because two inline scripts in `BaseLayout.astro` (the theme-init boot and the View Transitions flash-killer) can't be nonce-tagged without restructuring how they're emitted. The next step is to generate a per-request nonce in `src/middleware.ts`, inject it onto those two scripts in `BaseLayout.astro`, and replace `'unsafe-inline'` with `'nonce-'` in `script-src`. Astro View Transitions can complicate nonce propagation across the client-side swap, so this is its own task — the file header in `src/middleware.ts` tracks it. --- ## 6. Updating the site ```bash git pull docker compose up -d --build ``` The build stage reinstalls deps and rebuilds `dist/`; the runtime stage swaps in the new artifact with zero config. --- ## 7. Without Docker (bare Node) If you prefer to run Node directly on the host: ```bash npm ci npm run build HOST=0.0.0.0 PORT=4321 SITE_URL=https://gregkilmer.com node ./dist/server/entry.mjs ``` Run it under a process manager (systemd, pm2) so it restarts on crash/boot. --- ## 8. Verifying security headers after deploy ```bash # App-set headers (from src/middleware.ts) curl -sI https://gregkilmer.com | grep -iE 'content-security|x-frame|x-content|referrer|permissions' # Proxy-set HSTS + redirect (Nginx Proxy Manager) curl -sI https://gregkilmer.com | grep -i 'strict-transport' curl -sI http://gregkilmer.com # expect 301 -> https:// # security.txt reachable curl -s https://gregkilmer.com/.well-known/security.txt ``` All five app headers (`content-security-policy`, `x-frame-options`, `x-content-type-options`, `referrer-policy`, `permissions-policy`) should be present on every route, including `/api/contact`. The `strict-transport- security` header should appear only on the HTTPS response, and the plain-HTTP request should redirect.