[object Object]

← back to Dw Domain Fleet

fleet: DNS cutover + SSL + deploy scripts, PROGRESS.md

32e95f98e38125486b5fac05d518e94915b4558f · 2026-05-18 20:38:37 -0700 · Steve

Files touched

Diff

commit 32e95f98e38125486b5fac05d518e94915b4558f
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 18 20:38:37 2026 -0700

    fleet: DNS cutover + SSL + deploy scripts, PROGRESS.md
---
 PROGRESS.md              |  62 ++++++++++++++++++++++++++
 scripts/deploy-fleet.sh  |  32 ++++++++++++++
 scripts/dns-cutover.js   | 112 +++++++++++++++++++++++++++++++++++++++++++++++
 scripts/provision-ssl.sh |  22 ++++++++++
 4 files changed, 228 insertions(+)

diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..5951b54
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,62 @@
+# DW Domain Fleet — 66-domain standalone-storefront build
+
+Target: each of 66 domains currently 301-forwarding to designerwallcoverings.com
+becomes its own live, standalone, DW-spec storefront.
+
+## Architecture
+- **Shared parameterized codebase** at `~/Projects/dw-domain-fleet/`.
+  One `server.js`; per-domain config under `sites/<slug>.json`; pm2 runs one
+  instance per domain (`SITE` + `PORT` env). Catalog snapshot from live
+  `designerwallcoverings.com/products.json` → `data/catalog.json` (15,000 products).
+- 12 palettes × 4 font pairs → distinct visual identity per domain (anti-doorway-page).
+- `asseeninmovies` is NOT in the shared codebase — it has a pre-existing standalone
+  project at `~/Projects/asseeninmovies` (audit-and-bring-to-spec, tracked separately).
+
+## Status legend
+BUILT = config + boot-test pass · DEPLOYED = live on Kamatera pm2 · DNS = A record cut to 45.61.58.125 · SSL = certbot done · VERIFIED = curl 200 own content
+
+## Fleet (65 shared + 1 separate)
+
+| # | Domain | BUILT | DEPLOYED | DNS | SSL | VERIFIED |
+|---|--------|-------|----------|-----|-----|----------|
+| exemplar | grassclothwallcovering.com | YES (local-verified) | pending | no | no | no |
+| 1-65 | all other shared-codebase domains | YES (65/65 boot-test) | pending | no | no | no |
+| sep | asseeninmovies.com | pre-existing project — audit pending | — | — | — | — |
+
+## Done so far
+- dw-site-build skill read in full.
+- Shared codebase built: `server.js` + `shared/{catalog,sort,render}.js`.
+- Catalog pulled: 15,000 live DW products, 14,975 with images.
+- 65 per-domain configs generated (`sites/*.json`), each with distinct
+  palette/font/niche/copy. Boot-test: 65/65 pass, 0 thin niches.
+- Grasscloth exemplar verified locally on :9931 — hero rotation (11 slides),
+  Gucci nav, Enter CTA, sort+density grid w/ localStorage, ns-modal,
+  vendor scrubbed, 0 banned "wallpaper" words in editorial copy, valid JSON-LD.
+- pm2 `ecosystem.config.js` + 65 nginx vhosts generated.
+- DNS cutover script (`scripts/dns-cutover.js`) + SSL script + deploy script written.
+
+## Each site ships (DW-spec)
+- Rotating hero (full-bleed crossfade ≥3 imgs, else 4-square) — `background-position:center 14%` auto-crop.
+- Gucci `≡ MENU` corner-nav UR · brand wordmark UL · centered-bottom Enter CTA.
+- Catalog grid: sort `<select>` (9 modes) + density range slider, both localStorage-persisted (`<slug>_sort`/`_density`).
+- Dark/light toggle, anti-flash inline script.
+- ns-modal: chip click → full-details modal, vendor scrubbed to "Designer Wallcoverings".
+- Memo-sample CTA → `designerwallcoverings.com/products/{handle}#sample`.
+- helmet + CSP + rate-limit security baseline.
+- JSON-LD Organization + canonical + og/twitter meta.
+- Word "Wallpaper" banned in all EDITORIAL copy (tagline/about/meta/hero-sub).
+  Domain-stem brand wordmark (e.g. wallpaperny.com → "Wallpaper NY") kept as-is —
+  the registered domain is the SEO asset.
+
+## Remaining
+1. Deploy fleet to Kamatera (`scripts/deploy-fleet.sh`).
+2. DNS cutover in batches of ~10 (`scripts/dns-cutover.js --batch-n 10 --offset N`).
+3. SSL provision per batch after propagation (`scripts/provision-ssl.sh`).
+4. Smoke-test each batch (curl 200 + own content, not DW redirect).
+5. Audit asseeninmovies separately.
+
+## Doorway-page / SEO note
+See final report. Several near-duplicate general-catalog domains
+(designerwallcovering / designerwallcover / designwallcoverings /
+designerswallcovering(s)) carry real generic-keyword equity individually
+but as a CLUSTER risk thin-content flags. Recommendation surfaced in report.
diff --git a/scripts/deploy-fleet.sh b/scripts/deploy-fleet.sh
new file mode 100755
index 0000000..8e10964
--- /dev/null
+++ b/scripts/deploy-fleet.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# deploy-fleet.sh — push dw-domain-fleet to Kamatera, install, pm2 start all 65,
+# install nginx vhosts (HTTP only — certbot adds 443 after DNS cutover).
+set -euo pipefail
+
+HOST=root@45.61.58.125
+REMOTE=/root/public-projects/dw-domain-fleet
+LOCAL="$(cd "$(dirname "$0")/.." && pwd)"
+
+echo "==> rsync $LOCAL -> $HOST:$REMOTE"
+ssh "$HOST" "mkdir -p $REMOTE"
+rsync -az --delete \
+  --exclude node_modules --exclude .git --exclude 'logs/*' --exclude '.env*' \
+  "$LOCAL/" "$HOST:$REMOTE/"
+
+echo "==> install deps + pm2 start"
+ssh "$HOST" "cd $REMOTE && npm install --omit=dev --silent && pm2 start ecosystem.config.js && pm2 save"
+
+echo "==> install nginx vhosts"
+ssh "$HOST" "cp $REMOTE/data/nginx/*.conf /etc/nginx/sites-available/ && \
+  for f in $REMOTE/data/nginx/*.conf; do \
+    b=\$(basename \$f); ln -sf /etc/nginx/sites-available/\$b /etc/nginx/sites-enabled/\$b; \
+  done && nginx -t && systemctl reload nginx"
+
+echo "==> smoke test (origin, via Host header)"
+FAIL=0
+for d in $(ls "$LOCAL/data/nginx/" | sed 's/.conf//'); do
+  CODE=$(ssh "$HOST" "curl -s -o /dev/null -w '%{http_code}' -H 'Host: $d' http://127.0.0.1/health" || echo 000)
+  if [ "$CODE" != "200" ]; then echo "  FAIL $d ($CODE)"; FAIL=$((FAIL+1)); else echo "  ok   $d"; fi
+done
+echo "==> deploy complete · $FAIL failures"
+exit $FAIL
diff --git a/scripts/dns-cutover.js b/scripts/dns-cutover.js
new file mode 100644
index 0000000..e332159
--- /dev/null
+++ b/scripts/dns-cutover.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+/**
+ * dns-cutover.js — repoint fleet domains from GoDaddy 301-forwarding to the
+ * Kamatera origin (45.61.58.125), and remove the GoDaddy domain-forwarding
+ * rule so the domain stops redirecting to designerwallcoverings.com.
+ *
+ * Usage:
+ *   node dns-cutover.js --check                 # report current DNS state for all
+ *   node dns-cutover.js --batch <domain,...>    # cut over a comma-list
+ *   node dns-cutover.js --batch-n <N> --offset <O>   # cut over N domains starting at O
+ *
+ * Requires GODADDY_API_KEY / GODADDY_API_SECRET in env (sourced from
+ * ~/Projects/secrets-manager/.env by the wrapper).
+ *
+ * SAFE BY DEFAULT: --check makes zero writes. Cutover only on --batch / --batch-n.
+ */
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const ORIGIN_IP = '45.61.58.125';
+const KEY = process.env.GODADDY_API_KEY;
+const SECRET = process.env.GODADDY_API_SECRET;
+if (!KEY || !SECRET) { console.error('FATAL: GODADDY_API_KEY / GODADDY_API_SECRET not set'); process.exit(1); }
+
+const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'manifest.json'), 'utf8'));
+const AUTH = `sso-key ${KEY}:${SECRET}`;
+
+function api(method, p, body) {
+  return new Promise((resolve, reject) => {
+    const data = body ? JSON.stringify(body) : null;
+    const req = https.request('https://api.godaddy.com' + p, {
+      method,
+      headers: {
+        Authorization: AUTH,
+        'Content-Type': 'application/json',
+        ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {})
+      }
+    }, (res) => {
+      let b = '';
+      res.on('data', c => (b += c));
+      res.on('end', () => resolve({ status: res.statusCode, body: b }));
+    });
+    req.on('error', reject);
+    if (data) req.write(data);
+    req.end();
+  });
+}
+
+const FORWARDING_IPS = ['15.197.', '3.33.', '3.223.', '34.', '23.']; // GoDaddy/AWS forwarding ranges (heuristic)
+
+async function checkOne(domain) {
+  const r = await api('GET', `/v1/domains/${domain}/records/A/@`);
+  let a = [];
+  try { a = JSON.parse(r.body); } catch (e) {}
+  const ips = a.map(x => x.data);
+  const onOrigin = ips.includes(ORIGIN_IP);
+  const forwarding = ips.some(ip => FORWARDING_IPS.some(f => ip.startsWith(f)));
+  return { domain, ips, onOrigin, forwarding };
+}
+
+async function cutOne(domain) {
+  // 1. replace the @ A record(s) with the Kamatera origin
+  const put = await api('PUT', `/v1/domains/${domain}/records/A/@`, [
+    { data: ORIGIN_IP, ttl: 600 }
+  ]);
+  // 2. remove GoDaddy domain forwarding (DELETE returns 204 or 404 if none)
+  const del = await api('DELETE', `/v1/domains/${domain}/forwards/${domain}`);
+  return { domain, aStatus: put.status, fwdStatus: del.status };
+}
+
+(async () => {
+  const arg = (name) => {
+    const i = process.argv.indexOf(name);
+    return i >= 0 ? process.argv[i + 1] : null;
+  };
+
+  if (process.argv.includes('--check')) {
+    console.log(`${'DOMAIN'.padEnd(34)} ON_ORIGIN  FORWARDING  CURRENT_A`);
+    for (const m of manifest) {
+      const c = await checkOne(m.domain);
+      console.log(`${m.domain.padEnd(34)} ${String(c.onOrigin).padEnd(10)} ${String(c.forwarding).padEnd(11)} ${c.ips.join(',')}`);
+      await new Promise(r => setTimeout(r, 200));
+    }
+    return;
+  }
+
+  let targets = [];
+  const batch = arg('--batch');
+  const batchN = arg('--batch-n');
+  if (batch) {
+    targets = batch.split(',').map(s => s.trim()).filter(Boolean);
+  } else if (batchN) {
+    const off = parseInt(arg('--offset') || '0', 10);
+    targets = manifest.slice(off, off + parseInt(batchN, 10)).map(m => m.domain);
+  } else {
+    console.error('Specify --check, --batch <list>, or --batch-n <N> --offset <O>');
+    process.exit(1);
+  }
+
+  console.log(`Cutting over ${targets.length} domain(s) → ${ORIGIN_IP}`);
+  for (const d of targets) {
+    try {
+      const r = await cutOne(d);
+      console.log(`  ${d}: A=${r.aStatus} forward-delete=${r.fwdStatus}`);
+    } catch (e) {
+      console.log(`  ${d}: ERROR ${e.message}`);
+    }
+    await new Promise(r => setTimeout(r, 400));
+  }
+  console.log('Done. DNS propagation ~5-30 min. Smoke-test before next batch.');
+})();
diff --git a/scripts/provision-ssl.sh b/scripts/provision-ssl.sh
new file mode 100755
index 0000000..0cc3053
--- /dev/null
+++ b/scripts/provision-ssl.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# provision-ssl.sh — run certbot --nginx for a batch of fleet domains AFTER
+# their DNS has cut over to 45.61.58.125 and propagated.
+#
+# Usage:  ssh root@45.61.58.125 'bash -s' < provision-ssl.sh domain1 domain2 ...
+# certbot rate limit: 50 certs/registered-domain/week, 5 duplicate/week.
+# Each domain here is a distinct registered domain so 65 is well under the cap;
+# still — run in batches of ~10 and check output before continuing.
+set -uo pipefail
+EMAIL=info@designerwallcoverings.com
+OK=0; FAIL=0
+for d in "$@"; do
+  echo "==> certbot $d"
+  if certbot --nginx -d "$d" -d "www.$d" --non-interactive --agree-tos \
+       -m "$EMAIL" --redirect --keep-until-expiring 2>&1 | tail -3; then
+    OK=$((OK+1))
+  else
+    echo "  FAIL $d"; FAIL=$((FAIL+1))
+  fi
+done
+nginx -t && systemctl reload nginx
+echo "==> SSL: $OK ok · $FAIL fail"

← d1bbf07 fleet: explicit display-name overrides, ecosystem + nginx ge  ·  back to Dw Domain Fleet  ·  fleet: SCOPE CORRECTION 66→44 (dtd Option B split) — 22 doma 2186562 →