← back to Dw Pairs Well
deploy bundle: kamatera-bootstrap.sh + .deploy.conf + README
776b36599908d22327e479a3e05146f22076f321 · 2026-05-13 08:36:15 -0700 · Steve Abrams
Files touched
A .deploy.confA deploy/README.mdA deploy/kamatera-bootstrap.sh
Diff
commit 776b36599908d22327e479a3e05146f22076f321
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 08:36:15 2026 -0700
deploy bundle: kamatera-bootstrap.sh + .deploy.conf + README
---
.deploy.conf | 4 ++
deploy/README.md | 72 +++++++++++++++++++++++++++++
deploy/kamatera-bootstrap.sh | 106 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 182 insertions(+)
diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..34ac79e
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,4 @@
+PROJECT_NAME=dw-pairs-well
+DEPLOY_PATH=/root/Projects/dw-pairs-well
+HEALTH_URL=https://pairs.designerwallcoverings.com/healthz
+PORT=9813
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..6e578f9
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,72 @@
+# dw-pairs-well — Kamatera deploy (3 steps, ~3 min)
+
+The local service runs on **`:9813`** (NOT 9799 — that's taken by a Python SimpleHTTP on Mac2). Production target: `https://pairs.designerwallcoverings.com`.
+
+## Step 1 — Add Cloudflare A record
+
+`designerwallcoverings.com` is on Cloudflare (`ezra.ns.cloudflare.com` / `kimora.ns.cloudflare.com`). Add one A record:
+
+| Type | Name | Content | Proxy | TTL |
+|------|-------|----------------|-------|-----|
+| A | pairs | 45.61.58.125 | ON | Auto |
+
+**Via the Cloudflare dashboard:** designerwallcoverings.com → DNS → Records → Add record.
+
+**Or via `flarectl` / curl** (if `CLOUDFLARE_API_TOKEN` and the zone ID are in your env):
+
+```bash
+# replace ZONE_ID with the designerwallcoverings.com zone id
+curl -sX POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"type":"A","name":"pairs","content":"45.61.58.125","ttl":1,"proxied":true}'
+```
+
+## Step 2 — rsync code to Kamatera
+
+From your Mac:
+
+```bash
+rsync -avz --delete \
+ --exclude node_modules --exclude .git --exclude .env \
+ ~/Projects/dw-pairs-well/ \
+ root@45.61.58.125:/root/Projects/dw-pairs-well/
+
+# Copy your .env separately (NOT the example), one-time:
+scp ~/Projects/dw-pairs-well/.env root@45.61.58.125:/root/Projects/dw-pairs-well/.env
+```
+
+The `.env` on Kamatera must have its own `DATABASE_URL` pointing at the **local** PG on Kamatera (e.g. `postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified`) — NOT the Mac→Kamatera tunnel port 15432.
+
+## Step 3 — Run the bootstrap
+
+```bash
+ssh root@45.61.58.125 'bash /root/Projects/dw-pairs-well/deploy/kamatera-bootstrap.sh'
+```
+
+The script is **idempotent** — re-running it is safe. It will:
+
+1. Verify node / pm2 / certbot are installed (installs certbot if missing)
+2. `npm install --omit=dev` in `/root/Projects/dw-pairs-well`
+3. Write `/etc/nginx/sites-available/pairs.designerwallcoverings.com` (HTTP block first, certbot adds the TLS block)
+4. DNS sanity check against `1.1.1.1`
+5. `certbot --nginx -d pairs.designerwallcoverings.com --redirect` (skipped if cert already exists)
+6. `pm2 start server.js --name dw-pairs-well` (or `pm2 reload` if it already exists), `pm2 save`
+7. Smoke test `https://pairs.designerwallcoverings.com/healthz` and a sample `/api/pairs?dw_sku=DWRW-74991`
+
+When you see `✅ done`, the storefront snippet in `DesignerWallcoverings-product.liquid` will start serving live pairings on the next page render. No theme push required — the snippet already hardcodes the production URL.
+
+## Rollback
+
+```bash
+ssh root@45.61.58.125 '
+ pm2 delete dw-pairs-well
+ rm -f /etc/nginx/sites-enabled/pairs.designerwallcoverings.com
+ rm -f /etc/nginx/sites-available/pairs.designerwallcoverings.com
+ systemctl reload nginx
+ certbot delete --cert-name pairs.designerwallcoverings.com -n
+'
+# Then remove the CF DNS record in the dashboard.
+```
+
+The Liquid block will then soft-fail-to-hidden (fetch fails → `root.hidden = true`).
diff --git a/deploy/kamatera-bootstrap.sh b/deploy/kamatera-bootstrap.sh
new file mode 100755
index 0000000..240abe2
--- /dev/null
+++ b/deploy/kamatera-bootstrap.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# Idempotent Kamatera bootstrap for dw-pairs-well.
+# Run AS ROOT on 45.61.58.125 after rsync has placed code at
+# /root/Projects/dw-pairs-well/ and after DNS pairs.designerwallcoverings.com
+# resolves to 45.61.58.125.
+#
+# ssh root@45.61.58.125 'bash /root/Projects/dw-pairs-well/deploy/kamatera-bootstrap.sh'
+
+set -euo pipefail
+
+PROJECT_DIR=/root/Projects/dw-pairs-well
+DOMAIN=pairs.designerwallcoverings.com
+PORT=9813
+PM2_NAME=dw-pairs-well
+NGINX_CONF=/etc/nginx/sites-available/${DOMAIN}
+NGINX_LINK=/etc/nginx/sites-enabled/${DOMAIN}
+
+echo "==[1/7] dependencies =================================================="
+node -v >/dev/null 2>&1 || { echo "ERROR: node not installed"; exit 1; }
+command -v pm2 >/dev/null 2>&1 || npm i -g pm2
+command -v certbot >/dev/null 2>&1 || { apt-get update && apt-get install -y certbot python3-certbot-nginx; }
+
+echo "==[2/7] npm install (production) ====================================="
+cd "$PROJECT_DIR"
+[ -f .env ] || { echo "ERROR: $PROJECT_DIR/.env missing — copy .env.example and edit"; exit 1; }
+npm install --omit=dev --silent --no-audit --no-fund
+
+echo "==[3/7] nginx vhost (HTTP only, certbot will add the TLS block) ======"
+cat > "${NGINX_CONF}" <<NGINX
+server {
+ listen 80;
+ listen [::]:80;
+ server_name ${DOMAIN};
+
+ # HTTP is left open here ONLY long enough for certbot's HTTP-01 challenge.
+ # Certbot will rewrite this file to redirect → HTTPS after issuance.
+ location /.well-known/acme-challenge/ { root /var/www/html; }
+
+ location / {
+ proxy_pass http://127.0.0.1:${PORT};
+ proxy_http_version 1.1;
+ 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;
+ proxy_connect_timeout 5s;
+ proxy_read_timeout 30s;
+ }
+
+ access_log /var/log/nginx/${DOMAIN}.access.log;
+ error_log /var/log/nginx/${DOMAIN}.error.log;
+}
+NGINX
+ln -sfn "${NGINX_CONF}" "${NGINX_LINK}"
+nginx -t
+systemctl reload nginx
+
+echo "==[4/7] DNS sanity check (must already point at 45.61.58.125) ========"
+RESOLVED=$(dig +short "${DOMAIN}" @1.1.1.1 | head -1 || true)
+if [ "${RESOLVED}" != "45.61.58.125" ] && [ -n "${RESOLVED}" ]; then
+ # CF proxy returns CF IPs (104.x / 172.67.x) — that's fine
+ case "${RESOLVED}" in
+ 104.*|172.67.*|172.64.*|108.162.*) echo " → Cloudflare-proxied: ${RESOLVED} (ok)";;
+ *) echo " !! WARN: ${DOMAIN} → ${RESOLVED} (expected 45.61.58.125 or CF). Continuing anyway." ;;
+ esac
+elif [ -z "${RESOLVED}" ]; then
+ echo " !! ERROR: ${DOMAIN} does not resolve. Add the DNS record first."
+ exit 1
+else
+ echo " → resolves direct: 45.61.58.125 (ok)"
+fi
+
+echo "==[5/7] Let's Encrypt cert ==========================================="
+if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then
+ certbot --nginx --non-interactive --agree-tos \
+ --email steve@designerwallcoverings.com \
+ -d "${DOMAIN}" --redirect
+else
+ echo " → cert already exists for ${DOMAIN}, skipping issuance"
+fi
+nginx -t && systemctl reload nginx
+
+echo "==[6/7] pm2 start ===================================================="
+cd "${PROJECT_DIR}"
+if pm2 describe "${PM2_NAME}" >/dev/null 2>&1; then
+ pm2 reload "${PM2_NAME}"
+else
+ PORT=${PORT} pm2 start server.js --name "${PM2_NAME}" --update-env
+fi
+pm2 save
+
+echo "==[7/7] smoke test ==================================================="
+sleep 2
+echo "--- local :${PORT}/healthz ---"
+curl -sS "http://127.0.0.1:${PORT}/healthz" || true
+echo
+echo "--- https://${DOMAIN}/healthz ---"
+curl -sS "https://${DOMAIN}/healthz" || true
+echo
+echo "--- https://${DOMAIN}/api/pairs?dw_sku=DWRW-74991 (first pair only) ---"
+curl -sS "https://${DOMAIN}/api/pairs?dw_sku=DWRW-74991" \
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); p=d["pairs"][0] if d.get("pairs") else None; print(p["title"], "—", "score", p["score"]) if p else print("no pairs")' \
+ || true
+
+echo
+echo "✅ done. http://${DOMAIN}/ should now redirect to https://${DOMAIN}/"
← 1d3d2f3 initial scaffold: Pairs Well With This Design service (Expre
·
back to Dw Pairs Well
·
Design Coordinate v2: palette + SW/DE paint match per produc 72fbd05 →