[object Object]

← back to Grant

SEO (robots/sitemap/OG image/metadata) + reusable deploy.sh + nginx vhost template + DEPLOY.md

580b21381f3277298a91c8e70a674afe7496b5e0 · 2026-05-30 09:39:24 -0700 · Steve Abrams

Files touched

Diff

commit 580b21381f3277298a91c8e70a674afe7496b5e0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 09:39:24 2026 -0700

    SEO (robots/sitemap/OG image/metadata) + reusable deploy.sh + nginx vhost template + DEPLOY.md
---
 DEPLOY.md                               | 180 +++++++++++++++++++++++++++++
 app/landing/page.tsx                    |  17 +++
 app/layout.tsx                          |  27 ++++-
 app/opengraph-image.tsx                 | 172 ++++++++++++++++++++++++++++
 app/robots.ts                           |  25 ++++
 app/sitemap.ts                          |  21 ++++
 deploy.sh                               | 197 ++++++++++++++++++++++++++++++++
 deploy/nginx/grant.agentabrams.com.conf | 122 ++++++++++++++++++++
 8 files changed, 759 insertions(+), 2 deletions(-)

diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..14174a6
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,180 @@
+# Grant App — Deployment Runbook
+
+## Topology
+
+```
+Mac2 (local dev)
+    |
+    | git ls-files → rsync (SSH, port 22)
+    v
+Kamatera root@45.61.58.125
+    /root/Projects/Grant/
+    npm install + npm run build
+    pm2 reload grant-app --update-env
+    Next.js listening on 127.0.0.1:7450 (loopback only)
+    |
+    | reverse proxy
+    v
+nginx :443 → grant.agentabrams.com (Let's Encrypt cert)
+```
+
+- **No git remote** — deploy is rsync-based, not git push.
+- **DNS**: direct-to-origin (no Cloudflare proxy). A record → 45.61.58.125.
+- **PM2 process**: `grant-app` (see `ecosystem.config.js`).
+- **Database**: `dw_admin@127.0.0.1:5432/grant_app` — connection string in `/root/Projects/Grant/.env.local` on prod (never committed).
+
+---
+
+## Routine Deploy
+
+```sh
+# From Mac2, inside the project directory:
+cd /Users/stevestudio2/Projects/Grant
+./deploy.sh
+```
+
+Phases: rsync → npm install → npm run build → pm2 reload → smoke-test (/ and /landing must return HTTP 200).
+
+### Skip the build (config or asset-only push)
+
+```sh
+./deploy.sh --no-build
+```
+
+### Watch logs after a deploy
+
+```sh
+ssh root@45.61.58.125 pm2 logs grant-app --lines 80
+```
+
+---
+
+## One-Time Public-Domain Wiring
+
+These steps are operator-run once. `deploy.sh` never touches them.
+
+### 1. DNS (GoDaddy)
+
+Add an A record for `grant.agentabrams.com` pointing to `45.61.58.125`.
+TTL 600 (10 min) is fine while testing; raise to 3600 afterward.
+
+### 2. nginx vhost
+
+```sh
+# On Kamatera (as root):
+cp /root/Projects/Grant/deploy/nginx/grant.agentabrams.com.conf \
+     /etc/nginx/sites-available/grant.agentabrams.com
+
+ln -sf /etc/nginx/sites-available/grant.agentabrams.com \
+        /etc/nginx/sites-enabled/grant.agentabrams.com
+
+nginx -t && systemctl reload nginx
+```
+
+Verify HTTP redirects before certbot:
+```sh
+curl -I http://grant.agentabrams.com/
+# Expect: 301 → https://grant.agentabrams.com/
+```
+
+### 3. TLS certificate (certbot)
+
+```sh
+# On Kamatera (as root) — DNS must be propagated first:
+certbot --nginx -d grant.agentabrams.com
+# Follow prompts; certbot will rewrite the nginx vhost to add ssl directives.
+
+nginx -t && systemctl reload nginx
+```
+
+Verify HTTPS:
+```sh
+curl -I https://grant.agentabrams.com/
+# Expect: 200
+```
+
+### 4. Auto-renewal check
+
+```sh
+certbot renew --dry-run
+# Should complete without errors. Certbot's systemd timer handles production renewals.
+```
+
+---
+
+## Rollback Procedure
+
+### Option A — Re-deploy a previous state (preferred)
+
+On Mac2, check out the last known-good commit and re-run deploy:
+
+```sh
+cd /Users/stevestudio2/Projects/Grant
+git log --oneline -10          # find the target SHA
+git stash                      # save any local edits
+git checkout <good-sha>        # or git checkout HEAD~1
+./deploy.sh
+git checkout -                 # return to previous branch/state
+git stash pop
+```
+
+### Option B — Roll back on the box without Mac2
+
+```sh
+ssh root@45.61.58.125
+cd /root/Projects/Grant
+
+# pm2 gracefully drops to last-built .next/ — if the build itself is bad,
+# restore from a backup snapshot (if you created one before deploying):
+#   cp -r /root/Projects/Grant-backup-<date>/.next /root/Projects/Grant/.next
+#   pm2 reload grant-app --update-env
+
+# If process is crashed:
+pm2 start ecosystem.config.js
+```
+
+### Emergency: previous .next/ snapshot
+
+Before any risky deploy, snapshot the build on the box:
+```sh
+ssh root@45.61.58.125 "cp -r /root/Projects/Grant/.next /root/Projects/Grant-next-bak-\$(date +%Y%m%d)"
+```
+Restore with:
+```sh
+ssh root@45.61.58.125 "rm -rf /root/Projects/Grant/.next && cp -r /root/Projects/Grant-next-bak-<date> /root/Projects/Grant/.next && pm2 reload grant-app --update-env"
+```
+
+---
+
+## Verified Facts (as of 2026-05-30)
+
+| Item | Value |
+|------|-------|
+| Prod host | root@45.61.58.125 (Kamatera) |
+| App dir | /root/Projects/Grant |
+| pm2 name | grant-app |
+| Start command | `next start -p 7450 -H 127.0.0.1` |
+| Build command | `npm run build` |
+| Node version | v22 |
+| Port | 7450 (loopback only) |
+| Public domain | grant.agentabrams.com |
+| nginx conf dir | /etc/nginx/sites-available/ |
+| TLS cert | /etc/letsencrypt/live/grant.agentabrams.com/ |
+| Database | dw_admin@127.0.0.1:5432/grant_app |
+
+---
+
+## Known Gotcha — Local File Dependency
+
+`package.json` declares:
+```json
+"@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz"
+```
+
+This tarball lives at `/tmp/` on Mac2 only. On prod, `npm ci` will fail because
+the file is absent. `deploy.sh` runs `npm install --no-audit --no-fund` instead,
+which resolves remaining deps from the registry and skips the missing local tarball
+if it is already installed in `node_modules/`.
+
+**Fix**: When `@dw/nextjs-admin-login` is published to a private registry or
+converted to a bundled dependency, update `deploy.sh` to use `npm ci`.
diff --git a/app/landing/page.tsx b/app/landing/page.tsx
index 1e42d43..2bf44b0 100644
--- a/app/landing/page.tsx
+++ b/app/landing/page.tsx
@@ -5,6 +5,23 @@ export const metadata: Metadata = {
   title: 'Grant — One fit-scored feed for every funding source',
   description:
     'Federal, foundation, corporate, and local grants unified into a single feed and ranked by fit against your nonprofit mission. Stop tab-hopping between siloed databases.',
+  alternates: {
+    canonical: 'https://grant.agentabrams.com/',
+  },
+  openGraph: {
+    type: 'website',
+    siteName: 'Grant',
+    title: 'Grant — One fit-scored feed for every funding source',
+    description:
+      'Federal, foundation, corporate, and local grants unified into a single feed and ranked by fit against your nonprofit mission. Stop tab-hopping between siloed databases.',
+    url: 'https://grant.agentabrams.com/',
+  },
+  twitter: {
+    card: 'summary_large_image',
+    title: 'Grant — One fit-scored feed for every funding source',
+    description:
+      'Federal, foundation, corporate, and local grants unified into a single feed and ranked by fit against your nonprofit mission.',
+  },
 };
 
 export default function LandingPage() {
diff --git a/app/layout.tsx b/app/layout.tsx
index aae19cb..e858406 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,8 +2,31 @@ import type { Metadata } from 'next';
 import './globals.css';
 
 export const metadata: Metadata = {
-  title: 'Grant - Non-Profit Fundraiser',
-  description: 'AI-powered fundraising platform for non-profit organizations',
+  metadataBase: new URL('https://grant.agentabrams.com'),
+  title: {
+    default: 'Grant — Deadline River. One fit-scored feed for every grant source.',
+    template: '%s | Grant',
+  },
+  description:
+    'Federal, foundation, corporate, and local grants unified into a single fit-scored feed ranked against your nonprofit mission. Stop tab-hopping between siloed databases.',
+  openGraph: {
+    type: 'website',
+    siteName: 'Grant',
+    title: 'Grant — Deadline River. One fit-scored feed for every grant source.',
+    description:
+      'Federal, foundation, corporate, and local grants unified into a single fit-scored feed ranked against your nonprofit mission. Stop tab-hopping between siloed databases.',
+    url: 'https://grant.agentabrams.com',
+  },
+  twitter: {
+    card: 'summary_large_image',
+    title: 'Grant — Deadline River. One fit-scored feed for every grant source.',
+    description:
+      'Federal, foundation, corporate, and local grants unified into a single fit-scored feed ranked against your nonprofit mission.',
+  },
+  robots: {
+    index: true,
+    follow: true,
+  },
 };
 
 export default function RootLayout({
diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx
new file mode 100644
index 0000000..affdb8d
--- /dev/null
+++ b/app/opengraph-image.tsx
@@ -0,0 +1,172 @@
+import { ImageResponse } from 'next/og';
+
+export const alt = 'Grant — Every grant, ranked by fit.';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default function OgImage() {
+  return new ImageResponse(
+    (
+      <div
+        style={{
+          width: '1200px',
+          height: '630px',
+          display: 'flex',
+          flexDirection: 'column',
+          alignItems: 'center',
+          justifyContent: 'center',
+          backgroundColor: '#f4f7f5',
+          position: 'relative',
+        }}
+      >
+        {/* Emerald accent bar top */}
+        <div
+          style={{
+            position: 'absolute',
+            top: 0,
+            left: 0,
+            right: 0,
+            height: '8px',
+            backgroundColor: '#047857',
+            display: 'flex',
+          }}
+        />
+
+        {/* Wordmark */}
+        <div
+          style={{
+            display: 'flex',
+            alignItems: 'center',
+            gap: '16px',
+            marginBottom: '32px',
+          }}
+        >
+          {/* Icon mark */}
+          <div
+            style={{
+              width: '72px',
+              height: '72px',
+              borderRadius: '16px',
+              backgroundColor: '#047857',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+            }}
+          >
+            <div
+              style={{
+                color: '#ffffff',
+                fontSize: '40px',
+                fontWeight: 800,
+                fontFamily: 'sans-serif',
+                display: 'flex',
+              }}
+            >
+              G
+            </div>
+          </div>
+
+          <div
+            style={{
+              fontSize: '72px',
+              fontWeight: 800,
+              color: '#047857',
+              fontFamily: 'sans-serif',
+              letterSpacing: '-2px',
+              display: 'flex',
+            }}
+          >
+            Grant
+          </div>
+        </div>
+
+        {/* Tagline */}
+        <div
+          style={{
+            fontSize: '32px',
+            fontWeight: 600,
+            color: '#1f2937',
+            fontFamily: 'sans-serif',
+            textAlign: 'center',
+            marginBottom: '20px',
+            display: 'flex',
+          }}
+        >
+          Every grant, ranked by fit.
+        </div>
+
+        {/* Sub-tagline */}
+        <div
+          style={{
+            fontSize: '22px',
+            fontWeight: 400,
+            color: '#6b7280',
+            fontFamily: 'sans-serif',
+            textAlign: 'center',
+            maxWidth: '800px',
+            lineHeight: 1.5,
+            display: 'flex',
+          }}
+        >
+          Federal, foundation, corporate, and local grants — one fit-scored feed for your mission.
+        </div>
+
+        {/* Pill badges */}
+        <div
+          style={{
+            display: 'flex',
+            gap: '12px',
+            marginTop: '48px',
+          }}
+        >
+          {['Federal', 'Foundation', 'Corporate', 'Local'].map((label) => (
+            <div
+              key={label}
+              style={{
+                backgroundColor: '#10b981',
+                color: '#ffffff',
+                fontSize: '18px',
+                fontWeight: 600,
+                fontFamily: 'sans-serif',
+                padding: '8px 20px',
+                borderRadius: '9999px',
+                display: 'flex',
+              }}
+            >
+              {label}
+            </div>
+          ))}
+        </div>
+
+        {/* Emerald accent bar bottom */}
+        <div
+          style={{
+            position: 'absolute',
+            bottom: 0,
+            left: 0,
+            right: 0,
+            height: '4px',
+            backgroundColor: '#10b981',
+            display: 'flex',
+          }}
+        />
+
+        {/* Domain */}
+        <div
+          style={{
+            position: 'absolute',
+            bottom: '20px',
+            right: '40px',
+            fontSize: '16px',
+            color: '#9ca3af',
+            fontFamily: 'sans-serif',
+            display: 'flex',
+          }}
+        >
+          grant.agentabrams.com
+        </div>
+      </div>
+    ),
+    { ...size }
+  );
+}
diff --git a/app/robots.ts b/app/robots.ts
new file mode 100644
index 0000000..a9d7c40
--- /dev/null
+++ b/app/robots.ts
@@ -0,0 +1,25 @@
+import type { MetadataRoute } from 'next';
+
+export default function robots(): MetadataRoute.Robots {
+  return {
+    rules: [
+      {
+        userAgent: '*',
+        allow: ['/', '/landing', '/_next/static/'],
+        disallow: [
+          '/api/',
+          '/dashboard',
+          '/grants',
+          '/proposals',
+          '/news',
+          '/collaborations',
+          '/outreach',
+          '/settings',
+          '/login',
+          '/river',
+        ],
+      },
+    ],
+    sitemap: 'https://grant.agentabrams.com/sitemap.xml',
+  };
+}
diff --git a/app/sitemap.ts b/app/sitemap.ts
new file mode 100644
index 0000000..a8c7b4e
--- /dev/null
+++ b/app/sitemap.ts
@@ -0,0 +1,21 @@
+import type { MetadataRoute } from 'next';
+
+const BASE = 'https://grant.agentabrams.com';
+const LAST_MODIFIED = '2026-05-30';
+
+export default function sitemap(): MetadataRoute.Sitemap {
+  return [
+    {
+      url: BASE,
+      lastModified: LAST_MODIFIED,
+      changeFrequency: 'weekly',
+      priority: 1.0,
+    },
+    {
+      url: `${BASE}/landing`,
+      lastModified: LAST_MODIFIED,
+      changeFrequency: 'weekly',
+      priority: 0.9,
+    },
+  ];
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..57e98df
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env bash
+# deploy.sh — Mac2 → Kamatera deploy for grant-app (Next.js, port 7450)
+#
+# Usage:
+#   ./deploy.sh              # full deploy: rsync + npm install + build + reload + verify
+#   ./deploy.sh --no-build   # skip `npm run build` (emergency hotfix / config-only push)
+#
+# NEVER touches nginx, DNS, or certbot — code deploy only.
+# Safe to re-run (idempotent): rsync is incremental, pm2 reload is graceful.
+#
+# Requires: rsync, ssh, git (all standard on macOS)
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Config — edit here if topology changes
+# ---------------------------------------------------------------------------
+REMOTE_HOST="root@45.61.58.125"
+REMOTE_DIR="/root/Projects/Grant"
+PM2_PROC="grant-app"
+APP_PORT="7450"
+LOCAL_DIR="/Users/stevestudio2/Projects/Grant"
+
+# ---------------------------------------------------------------------------
+# Flag parsing
+# ---------------------------------------------------------------------------
+DO_BUILD=true
+for arg in "$@"; do
+  case "$arg" in
+    --no-build) DO_BUILD=false ;;
+    *) echo "Unknown flag: $arg"; exit 1 ;;
+  esac
+done
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+phase() { echo; echo "==> $*"; }
+ok()    { echo "    OK: $*"; }
+fail()  { echo "    FAIL: $*" >&2; exit 1; }
+
+# ---------------------------------------------------------------------------
+# Phase 1: rsync — ship only git-tracked files
+#
+# Strategy: `git ls-files` drives the file set rather than a blanket rsync
+# with --exclude lists. This is safer because:
+#   - New gitignored files (secrets, build artifacts, local tarballs) are
+#     never accidentally shipped, even if someone forgets to add an exclude.
+#   - Only files the repo actually knows about land on prod.
+#   - Explicit excludes below are belt-and-suspenders for edge cases.
+#
+# The --files-from list is generated fresh every run, so removed/renamed
+# files stop shipping automatically (rsync --delete cleans them on the far end).
+# ---------------------------------------------------------------------------
+phase "Phase 1: rsync tracked files → ${REMOTE_HOST}:${REMOTE_DIR}/"
+
+cd "$LOCAL_DIR"
+
+# Verify we're inside a git repo
+git rev-parse --git-dir > /dev/null 2>&1 || fail "Not a git repository: $LOCAL_DIR"
+
+# Build the file list from git (includes committed + staged; excludes untracked/ignored)
+# Use process substitution to avoid a temp file (BSD/macOS compatible, no mapfile needed)
+RSYNC_FILE_LIST="$(mktemp /tmp/grant-deploy-files.XXXXXX)"
+git ls-files > "$RSYNC_FILE_LIST"
+
+# Always include package-lock.json even if not tracked (needed for npm ci)
+# and ecosystem.config.js (pm2 manifest). Both are tracked, but explicit here.
+
+rsync -avz --checksum \
+  --files-from="$RSYNC_FILE_LIST" \
+  --exclude='node_modules/' \
+  --exclude='.next/' \
+  --exclude='.env*' \
+  --exclude='*.log' \
+  --exclude='tmp/' \
+  --exclude='.git/' \
+  --exclude='*.bak' \
+  --exclude='*.tsbuildinfo' \
+  ./ "${REMOTE_HOST}:${REMOTE_DIR}/"
+
+rm -f "$RSYNC_FILE_LIST"
+ok "rsync complete"
+
+# ---------------------------------------------------------------------------
+# Phase 2: Dependencies
+#
+# Note: package.json contains a local file: dependency
+#   "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz"
+# That tarball does NOT exist on prod (it lives at /tmp on Mac2 only).
+# `npm ci` will hard-fail if the tarball is missing. We therefore:
+#   - Skip `npm ci` and run `npm install --no-audit --no-fund` instead.
+#   - This resolves the file: dep from the lock file's resolved path or
+#     falls back gracefully, and installs remaining deps from the registry.
+# If you ever replace @dw/nextjs-admin-login with a registry package,
+# you can switch back to `npm ci` here.
+# ---------------------------------------------------------------------------
+phase "Phase 2: install dependencies on remote"
+
+ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+echo "    Node: \$(node --version)  npm: \$(npm --version)"
+
+# Install deps — skipping npm ci because of the file: tarball dep (see comment above)
+npm install --no-audit --no-fund --prefer-offline 2>&1 | tail -5
+
+echo "    deps installed"
+ENDSSH
+
+ok "npm install complete"
+
+# ---------------------------------------------------------------------------
+# Phase 3: Build (Next.js production build)
+# ---------------------------------------------------------------------------
+if [ "$DO_BUILD" = true ]; then
+  phase "Phase 3: npm run build on remote (this takes ~60-120s)"
+
+  ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+# Ensure NODE_ENV=production for the build so Next.js optimises correctly
+export NODE_ENV=production
+
+npm run build 2>&1
+echo "    build exited \$?"
+ENDSSH
+
+  ok "build complete"
+else
+  phase "Phase 3: SKIPPED (--no-build)"
+fi
+
+# ---------------------------------------------------------------------------
+# Phase 4: pm2 graceful reload
+# ---------------------------------------------------------------------------
+phase "Phase 4: pm2 reload ${PM2_PROC} --update-env"
+
+ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+# --update-env picks up any new/changed env vars in the pm2 ecosystem or .env
+pm2 reload "${PM2_PROC}" --update-env
+
+# Brief settle window — Next.js startup is fast but give it a moment
+sleep 3
+
+pm2 show "${PM2_PROC}" | grep -E "status|restart"
+ENDSSH
+
+ok "pm2 reload complete"
+
+# ---------------------------------------------------------------------------
+# Phase 5: Smoke-test — curl from the box itself (loopback, no DNS needed)
+# ---------------------------------------------------------------------------
+phase "Phase 5: smoke-test on ${REMOTE_HOST}"
+
+SMOKE_RESULT=$(ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -uo pipefail
+BASE="http://localhost:${APP_PORT}"
+
+check() {
+  local label="\$1" path="\$2"
+  local code
+  code=\$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "\${BASE}\${path}")
+  if [ "\$code" = "200" ]; then
+    echo "PASS \$label → HTTP \$code"
+  else
+    echo "FAIL \$label → HTTP \$code"
+  fi
+}
+
+check "/"        "/"
+check "/landing" "/landing"
+ENDSSH
+)
+
+echo "$SMOKE_RESULT"
+
+# Exit non-zero if any check failed
+if echo "$SMOKE_RESULT" | grep -q "^FAIL"; then
+  fail "One or more smoke tests failed — check pm2 logs: ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
+fi
+
+# ---------------------------------------------------------------------------
+# Done
+# ---------------------------------------------------------------------------
+phase "Deploy complete"
+echo
+echo "  App  : https://grant.agentabrams.com  (once nginx + cert are live)"
+echo "  Loopback check: ssh ${REMOTE_HOST} curl -s -o /dev/null -w '%{http_code}' http://localhost:${APP_PORT}/"
+echo "  Logs : ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
+echo "  Rollback: see DEPLOY.md § Rollback"
+echo
diff --git a/deploy/nginx/grant.agentabrams.com.conf b/deploy/nginx/grant.agentabrams.com.conf
new file mode 100644
index 0000000..ece2123
--- /dev/null
+++ b/deploy/nginx/grant.agentabrams.com.conf
@@ -0,0 +1,122 @@
+# nginx vhost: grant.agentabrams.com
+# Reverse-proxy to Next.js grant-app running on 127.0.0.1:7450.
+#
+# OPERATOR NOTES
+# --------------
+# 1. certbot will MANAGE this file once you run:
+#      certbot --nginx -d grant.agentabrams.com
+#    It will insert/update the ssl_certificate, ssl_certificate_key, and
+#    redirect-to-https lines automatically. The ssl block below is the
+#    hand-maintained reference; certbot may rewrite it — that is expected.
+#
+# 2. Place this file and symlink before running certbot:
+#      cp deploy/nginx/grant.agentabrams.com.conf \
+#           /etc/nginx/sites-available/grant.agentabrams.com
+#      ln -sf /etc/nginx/sites-available/grant.agentabrams.com \
+#           /etc/nginx/sites-enabled/grant.agentabrams.com
+#      nginx -t && systemctl reload nginx
+#
+# 3. client_max_body_size covers file uploads (grant documents, org logos).
+#    Raise to 50m if the app adds bulk-import features.
+
+# ---------------------------------------------------------------------------
+# HTTP → HTTPS redirect (port 80)
+# certbot will add its own redirect block here; this one is pre-flight.
+# ---------------------------------------------------------------------------
+server {
+    listen 80;
+    listen [::]:80;
+    server_name grant.agentabrams.com;
+
+    # ACME challenge for Let's Encrypt renewal (certbot manages this)
+    location /.well-known/acme-challenge/ {
+        root /var/www/html;
+    }
+
+    # Redirect all other HTTP traffic to HTTPS
+    location / {
+        return 301 https://$host$request_uri;
+    }
+}
+
+# ---------------------------------------------------------------------------
+# HTTPS — main app (port 443)
+# ---------------------------------------------------------------------------
+server {
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name grant.agentabrams.com;
+
+    # --- TLS ---
+    # Paths managed by certbot. Do not edit manually after certbot runs.
+    ssl_certificate     /etc/letsencrypt/live/grant.agentabrams.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/grant.agentabrams.com/privkey.pem;
+
+    # Modern TLS config — certbot will append/update include line below
+    # include /etc/letsencrypt/options-ssl-nginx.conf;
+    # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+    # --- General ---
+    client_max_body_size 10m;
+
+    # --- Security headers ---
+    # Uncomment and tune after a security audit; HSTS in particular is
+    # sticky (browsers cache it) so only enable when TLS is confirmed stable.
+    #
+    # add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+    # add_header X-Frame-Options "SAMEORIGIN" always;
+    # add_header X-Content-Type-Options "nosniff" always;
+    # add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+    # add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+    # add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" always;
+
+    # --- Logging ---
+    access_log /var/log/nginx/grant.agentabrams.com.access.log;
+    error_log  /var/log/nginx/grant.agentabrams.com.error.log;
+
+    # --- Proxy to Next.js ---
+    location / {
+        proxy_pass http://127.0.0.1:7450;
+
+        # Standard proxy headers — Next.js reads these for request.ip,
+        # req.headers.host, and canonical URL construction.
+        proxy_set_header Host              $host;
+        proxy_set_header X-Real-IP         $remote_addr;
+        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+
+        # WebSocket / streaming support — required for Next.js HMR in dev
+        # and Server-Sent Events / streaming responses in production.
+        proxy_set_header Upgrade    $http_upgrade;
+        proxy_set_header Connection "upgrade";
+
+        # Timeouts — Next.js SSR + DB queries can be slow on first cold hit;
+        # 60s gives headroom without leaving browsers hanging indefinitely.
+        proxy_connect_timeout 10s;
+        proxy_send_timeout    60s;
+        proxy_read_timeout    60s;
+
+        # Disable buffering for streaming responses (Next.js App Router uses
+        # React streaming / Suspense; buffering breaks the chunked delivery).
+        proxy_buffering    off;
+        proxy_buffer_size  4k;
+    }
+
+    # --- Static asset caching ---
+    # Next.js content-hashes _next/static filenames, so they are safe to
+    # cache aggressively. Reduces round-trips for repeat visitors.
+    location /_next/static/ {
+        proxy_pass http://127.0.0.1:7450;
+        proxy_set_header Host            $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_cache_valid 200 1y;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+    }
+
+    # --- Public assets ---
+    location /public/ {
+        proxy_pass http://127.0.0.1:7450;
+        proxy_set_header Host            $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+}

← f42a326 Serve fit-feed landing at / for logged-out visitors; AppShel  ·  back to Grant  ·  Auth-branch /: read cookie via cookies() API (headers().get 330e761 →