← back to Dw Domain Fleet
fleet: DNS cutover + SSL + deploy scripts, PROGRESS.md
32e95f98e38125486b5fac05d518e94915b4558f · 2026-05-18 20:38:37 -0700 · Steve
Files touched
A PROGRESS.mdA scripts/deploy-fleet.shA scripts/dns-cutover.jsA scripts/provision-ssl.sh
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 →