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

346 lines
14 KiB
Markdown

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