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:
- 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. 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. Leavepreloadoff until you've run the site a while and confirmed all subdomains serve HTTPS.X-Forwarded-Foroverwrite — critical for the contact API's rate limiter. Add to the proxy host's Custom Nginx Configuration:Theadd_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;X-Forwarded-For $remote_addrline overwrites (not appends) any client-supplied value, closing a rate-limit bypass where an attacker spoofs a fresh IP per request. (/api/contactreads 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-originfetchwith JSON from a malicious site is blocked by the browser's same-origin policy unless the request is "simple" (a JSON POST withContent-Type: application/jsonis 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/contactis per-worker (in-memory). For a single container behind the proxy this is fine. If you ever scale to multiple replicas, swap the in-memoryMapfor 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.