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:
- SMTP (if
SMTP_HOSTis set): sends a real email via nodemailer. Works with any SMTP relay (Gmail app-password, Mailgun, SES, your homelab mail server). - Webhook (if
CONTACT_WEBHOOK_URLis set and SMTP is not): POSTs a JSON payload{ name, email, message, text }. Great for a Discord/Slack/n8n inbound. - Neither configured: returns
503with 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 (
TXTon the domain): lists the relay's IP as an authorized sender. - DKIM (
TXTonselector._domainkey): signs outgoing messages so the relay's identity can be verified. - DMARC (
TXTon_dmarc): tells receivers how to handle SPF/DKIM failures (start atp=noneto monitor, tighten toquarantinelater). - 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:
- Create a widget at Cloudflare Turnstile. Use the Managed challenge mode (invisible, no puzzle).
- Put the site key in
TURNSTILE_SITE_KEYand the secret key inTURNSTILE_SECRET_KEY(both in.env). - The site key is public and ships to the browser; the secret stays
server-side. The API verifies each token against Cloudflare's
siteverifyendpoint 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.txtallows legitimate search engines (Google, Bing) but blocks known AI training bots (GPTBot, ClaudeBot, CCBot, Bytespider, etc.) and disallows/api/and/contactfor everyone. Note: robots.txt is voluntary; determined scrapers ignore it.noindexon/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@ormailto: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.