mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user