Files
com.gregkilmer.www/docs/DEPLOYMENT.md
T
2026-07-11 17:10:44 -04: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.