Files
com.gregkilmer.www/docs/DEPLOYMENT.md
T
2026-07-07 22:10:17 -04:00

8.0 KiB

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:

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

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:

docker compose logs -f greg-site

Healthcheck hits / every 30s. Verify locally:

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:

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:

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 (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. 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:

# 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 <meta name="robots" content="noindex, nofollow"> 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

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:

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.