# 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. 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. --- ## 6. 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.