Files
com.gregkilmer.www/legacy/docs/DEPLOYMENT.md
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00:00

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

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

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:

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

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