polypeptides.store — Stack Operations
Trigger: user mentions polypeptides, polypeptides.store, or the VPS running the store.
Quick Reference
| Layer | Tech | Port | Notes |
|---|---|---|---|
| Reverse proxy | Caddy | 80, 443 | Auto-TLS via Let's Encrypt |
| API | Fastify (Node) | 8080 | /healthz health check |
| Web | Nginx (Vite SPA) | 80 (internal) | Built with bun |
| DB | Postgres 16 | 5432 | polypeptides DB, 14 tables, 137 products |
| Storage | MinIO | 9000 | product-photos bucket, 146 images |
| Analytics | Python (systemd) | 3099 | Dashboard at /analytics/ |
| Mail (outbound) | Maddy | 587, 465 | TLS on 587, DKIM signed |
| Mail (inbound) | Python SMTP | 25 | Saves to /var/mail/support/ |
SSH Access
torsocks ssh root@85.203.26.214
# Password: xqcdhatguy1200
# Use SSH_ASKPASS pattern (see below)
SSH_ASKPASS pattern (used in execute_code blocks):
import subprocess, os, tempfile
PW = "xqcdhatguy1200"
HOST = "85.203.26.214"
fd, pw_script = tempfile.mkstemp(suffix='.py', prefix='_ssh_pw_')
with os.fdopen(fd, 'w') as f:
f.write(f'#!/usr/bin/env python3\nprint({PW!r})\n')
os.chmod(pw_script, 0o700)
env = os.environ.copy()
env['SSH_ASKPASS'] = pw_script
env['SSH_ASKPASS_REQUIRE'] = 'force'
env['DISPLAY'] = ':0'
def ssh(cmd, timeout=15):
full = ['torsocks', 'ssh', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'ConnectTimeout=15',
f'root@{HOST}', cmd]
proc = subprocess.run(full, env=env, capture_output=True, timeout=timeout)
return proc.returncode, proc.stdout.decode('utf-8', errors='replace'), proc.stderr.decode('utf-8', errors='replace')
# Clean up
os.unlink(pw_script)
Project root: /root/polypeptides/
Docker compose: /root/polypeptides/deploy/
Source code: /root/polypeptides/src/
Stack Management
Status check
cd /root/polypeptides/deploy && docker compose ps
Health checks
curl -s http://localhost:8080/healthz # API
curl -sI https://polypeptides.store # Web via HTTPS
docker exec deploy-db-1 psql -U polypeptides -d polypeptides -c "SELECT COUNT(*) FROM products;"
Restart individual service
cd /root/polypeptides/deploy && docker compose restart caddy
cd /root/polypeptides/deploy && docker compose up -d maddy
Frontend Build & Deployment
Architecture
The frontend is a Vite/React SPA built with bun. The production build injects a Supabase shim that replaces the real Supabase client with a self-hosted proxy speaking to the Fastify API at /api/*. This is done in the Dockerfile:
RUN cp deploy/web/supabase-shim.ts src/integrations/supabase/client.ts
The shim (deploy/web/supabase-shim.ts) implements: DB queries (.from().select/insert/...), storage (MinIO), auth (localStorage-backed customer auth + admin cookie auth), and functions.invoke() (mapped to API routes). When the frontend adds new Supabase calls, the shim must be updated to match.
Source file map (key files for common edits)
| File | Purpose | Notes |
|---|---|---|
src/data/reviews.ts |
Review data + stats functions | INTENTIONALLY EMPTY — all reviews on Trustpilot. Returns [], {rating:0,count:0}, {total:0,average:0}. Do NOT add fake reviews here. |
src/components/Reviews.tsx |
Homepage reviews section | Trustpilot-only CTA. No inline testimonials. |
src/components/product/ProductReviews.tsx |
Per-product review display | Shows "No reviews yet" since data/reviews.ts returns empty. |
src/pages/Reviews.tsx |
/reviews page | Trustpilot link + honest messaging about independent reviews. |
src/pages/About.tsx |
Shipping info, company details | Contains shipping time claims — keep in sync with banner. |
src/pages/Checkout.tsx |
Checkout flow | Contains eta: strings for shipping options. |
src/pages/FAQ.tsx |
FAQ page | Contains shipping time in answer text. |
src/components/CanadaShippingBanner.tsx |
Top-of-page shipping banner | Most visible shipping claim. |
Text-change pattern: grep ALL source before editing
When the user asks to change a claim that appears in the UI (shipping time, pricing, feature description), grep the ENTIRE source tree first — NOT just the most obvious file. Multiple components often hardcode the same string:
grep -rn '2-3 day\|2–3 business' /root/polypeptides/src/ --include='*.tsx' --include='*.ts'
Then use sed to replace in all files at once:
for f in src/pages/About.tsx src/pages/Checkout.tsx src/pages/FAQ.tsx src/components/CanadaShippingBanner.tsx; do
sed -i 's|2–3 business days|approximately 1 week|g' "$f"
done
Pitfall: Editing only the banner while leaving the same claim in About, Checkout, and FAQ produces inconsistent information visible to customers.
Rebuild & redeploy (full sequence)
CRITICAL: The web container has /tmp/index_enhanced.html mounted over its index.html (for JSON-LD injection). After every rebuild, the JS bundle filename changes (hash in filename). You MUST update both the image AND the mounted index.html.
1. Edit source or shim:
- Source: /root/polypeptides/src/
- Shim: /root/polypeptides/deploy/web/supabase-shim.ts
2. Rebuild image:
cd /root/polypeptides/deploy && docker compose build --no-cache web
3. Find the new JS bundle name:
docker run --rm deploy-web ls /usr/share/nginx/html/assets/ | grep 'index-.*\.js$'
# Example output: index-wcf6KHxv.js
4. Update the mounted index.html's script tag:
sed -i 's|index-OLDHASH.js|index-NEWHASH.js|g' /tmp/index_enhanced.html
5. Restart container (picks up new image + re-reads mounted file):
cd /root/polypeptides/deploy && docker compose up -d web
# OR: docker restart deploy-web-1
6. Verify:
curl -s https://polypeptides.store | grep 'index-.*\.js'
Supabase shim: what it must implement
When the frontend code calls any Supabase method, the shim must provide it. Current coverage (see references/supabase-shim.ts for full source):
| Supabase API | Shim mapping | Status |
|---|---|---|
.from(table).select/insert/update/delete |
/api/db/:table with query params |
Working |
.storage.from(bucket).upload/getPublicUrl/remove |
/api/assets/:bucket/* |
Working |
.auth.signUp/signInWithPassword/signOut/getUser/getSession/updateUser |
localStorage (customer) + /api/admin/* (admin) |
Working |
.auth.onAuthStateChange |
localStorage change listener | Working |
.functions.invoke('create-payment') |
POST /api/payments |
Working |
.functions.invoke('get-order') |
GET /api/orders/:number |
Working |
.functions.invoke('admin-login') |
POST /api/admin/login |
Working |
.channel/.removeChannel |
No-op stubs | Working |
Common pitfall: Frontend code calls a Supabase method not yet in the shim → "X is not a function" error at runtime. Fix: add the method to the shim, rebuild, update index.html, redeploy.
The full shim source is archived at references/supabase-shim.ts for reference when extending.
Testing the API (port access note)
The API container exposes port 8080 only inside the compose network (not to the VPS host). You cannot curl localhost:8080 from the VPS. Test through Caddy instead:
# Correct — goes through Caddy → api:8080
curl -s https://polypeptides.store/api/payments -X POST -H 'Content-Type: application/json' -d '{...}'
# Also works — from inside any compose container
docker exec deploy-caddy-1 wget -qO- http://api:8080/healthz
# Does NOT work — port not published to host
curl -s http://localhost:8080/healthz
Email (Maddy)
Check Maddy status
docker logs deploy-maddy-1 --tail=20
DKIM key extraction
docker exec deploy-maddy-1 cat /data/dkim_keys/polypeptides.store_default.dns
Test TLS
echo 'QUIT' | openssl s_client -starttls smtp -connect localhost:587 -quiet
Maddy TLS Setup (full workflow)
Maddy out-of-the-box has TLS disabled. To enable:
1. Generate self-signed cert on the VPS host:
mkdir -p /root/polypeptides/deploy/certs
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
-keyout /root/polypeptides/deploy/certs/mail.key \
-out /root/polypeptides/deploy/certs/mail.crt \
-subj "/CN=mail.polypeptides.store" \
-addext "subjectAltName=DNS:mail.polypeptides.store,DNS:polypeptides.store"
2. Write maddy.conf with TLS enabled (full template at references/maddy.conf). Key line:
tls file /data/certs/mail.crt /data/certs/mail.key
Port 587 uses STARTTLS; port 465 needs tls keyword inside the smtp block:
smtp tcp://0.0.0.0:465 {
tls
...
}
3. Mount certs in docker-compose.yml — add to maddy's volumes:
- ./certs:/data/certs:ro
4. Redeploy:
cd /root/polypeptides/deploy && docker compose up -d maddy
5. Verify: Maddy logs should NOT show "TLS is disabled" warning. Test with openssl s_client.
Inbound mail check
ls -la /var/mail/support/
cat /var/mail/support/<latest>.txt
Known limitation: No email triggers wired in
The mail.js module exists with a working sendMail() function (nodemailer, configured for Maddy on port 587), but no route imports or calls it. No automated emails fire for any event:
- No order confirmation on checkout
- No payment receipt on payment confirmation
- No shipping notification on tracking added
- No welcome/signup email
- No password reset
To wire up: import sendMail from ../mail.js into the relevant route (e.g. routes/payments.js for payment confirmation) and call it when the event occurs. The SMTP config in .env is already correct.
DNS Records
Domain: polypeptides.store
Nameservers: dns1.dnshost.to / dns2.dnshost.to
Managed via HostTechnology panel.
Full record set (MX, SPF, DMARC, DKIM, A records) in references/dns-records.md.
SEO & Search Engine Visibility
SEO & Search Engine Visibility
The site is a Vite SPA served by nginx — search engines see <div id="root"></div> (empty shell, ~1,942 bytes). SEO improvements are layered on top without rebuilding the frontend.
Architecture: Two-layer SEO
Layer 1 — Static HTML (Caddy file_server): Blog posts, tool pages, and now product pages are served as static HTML files from /var/www/seo/ BEFORE the SPA catch-all. These are generated by scripts from the PostgreSQL database.
Layer 2 — Prerender (for SPA pages): For dynamic SPA pages not covered by static HTML, Caddy detects bot user agents and routes to the prerender service on port 3000.
CRITICAL: Prerender API access. The prerender runs as a systemd service on the VPS host. It CANNOT reach Docker containers by name (http://api:8080 does not resolve). Use https://polypeptides.store/api/... (through Caddy) or http://172.18.0.1:8080 (Docker network gateway) instead. The original code used http://api:8080 which silently failed — Product JSON-LD and meta tags were NEVER injected until this was fixed.
Prerender is fragile. The systemd service and Puppeteer process can hang during restarts (systemctl restart times out over SSH). The kill-restart sequence is: ss -tlnp | grep 3000 to get PID, then kill -9 $PID, then systemctl start prerender. If the prerender is down, static pages still work.
Static Product Pages (NEW — preferred for SEO)
Instead of relying on the prerender for product page SEO, we generate 137 static HTML files with unique meta tags.
Script: /root/polypeptides/deploy/gen_product_pages.sh
Output: /var/www/seo/products/<product-id> (one file per product, no .html extension)
Also generates: /var/www/seo/products/index (full product catalog page)
Caddy route (to add manually):
handle /product/* {
root * /var/www/seo
file_server
}
Each static product page includes:
- Unique
<title>: "{Name} — {Category} Research Compound | Polypeptides" - Unique
<meta name="description">with product name, purity, category - Correct
<link rel="canonical">to the product URL <meta property="og:*">tags (title, description, image, price)- Product + Organization + BreadcrumbList JSON-LD
- Product specs table (category, purity, price, availability)
- PubChem link (if CID exists)
- Internal links to shop, blog posts, homepage
Regenerate after product changes:
bash /root/polypeptides/deploy/gen_product_pages.sh
SEO Files Overview
All SEO files live in /var/www/seo/ and are served directly by Caddy BEFORE the SPA catch-all:
| File | URL | Purpose | Generator |
|---|---|---|---|
robots.txt |
/robots.txt |
Crawl directives + Sitemap pointer | Manual |
sitemap.xml |
/sitemap.xml |
137 products + static pages + blog posts | gen_sitemap.sh (daily cron) |
llms.txt |
/llms.txt |
AI agent discovery (ChatGPT, Claude, Perplexity) | Manual |
llms-full.txt |
/llms-full.txt |
Full product listing for AI training data | gen_llms_full.sh (via sitemap cron) |
rss.xml |
/rss.xml |
RSS feed: 5 blog posts + 50 newest products | gen_rss.sh (every 6h cron) |
blog/ |
/blog/* |
Static HTML blog posts with Article+FAQ JSON-LD | Manual |
tools/ |
/tools/* |
Static HTML tool pages | Manual |
products/ |
/product/* |
Static HTML product pages with full SEO | gen_product_pages.sh |
og-image.png |
/og-image.png |
Open Graph image for social shares | Manual (ImageMagick) |
favicon.ico/svg/png |
/favicon.* |
Favicon files | Served from /var/www/seo/ via Caddy |
analytics.js |
/analytics.js |
Client-side analytics tracker | Served from /var/www/seo/ via Caddy |
Favicon & Icons
Favicon files are served by Caddy from /var/www/seo/, not by nginx (cross-container file access doesn't work after Docker rebuilds). Caddy routes:
handle /favicon.svg → root * /var/www/seo, file_server, Content-Type image/svg+xml
handle /favicon.ico → root * /var/www/seo, file_server, Content-Type image/x-icon
handle /favicon-32x32.png → root * /var/www/seo, file_server, Content-Type image/png
handle /apple-touch-icon.png → root * /var/www/seo, file_server, Content-Type image/png
Current favicon: Polymarket logo (pulled from polymarket.com/favicon.ico, 93×116 PNG).
Conversion: Use ImageMagick on the VPS:
curl -sL -o /tmp/pm.ico 'https://polymarket.com/favicon.ico'
convert /tmp/pm.ico -gravity center -extent 93x93 /tmp/pm_sq.png
convert /tmp/pm_sq.png -resize 16x16 /tmp/f16.png
convert /tmp/pm_sq.png -resize 32x32 /tmp/f32.png
convert /tmp/pm_sq.png -resize 48x48 /tmp/f48.png
convert /tmp/f16.png /tmp/f32.png /tmp/f48.png /tmp/favicon.ico
cp /tmp/f32.png /tmp/favicon-32x32.png
convert /tmp/pm_sq.png -resize 180x180 /tmp/apple-touch-icon.png
# Deploy to SEO dir
cp /tmp/favicon* /tmp/apple* /var/www/seo/
# Also copy to web container (survives rebuilds until next build)
docker cp /tmp/favicon.ico deploy-web-1:/usr/share/nginx/html/favicon.ico
# ... etc
After every docker compose build web, favicon files must be re-copied into the new container.
Homepage Meta Tags
Stored in /tmp/index_enhanced.html (mounted into web container). Current:
- Title: "Polypeptides — Canada's Trusted Research Peptide Supplier | Lab-Tested, Fast Shipping"
- Description: "Canada's trusted source for research peptides. Every product third-party tested with public COAs. Fast Canadian shipping, reliable support for researchers and labs nationwide."
- OG tags updated to match
Edit /tmp/index_enhanced.html then docker restart deploy-web-1 to apply.
Google Sitelinks
Sitelinks (sub-links under main search result) require:
- Clear site structure with well-linked pages ✓ (sitemap + navigation)
- WebSite schema with SearchAction ✓ (injected by prerender)
- BreadcrumbList on product pages ✓ (static pages include it)
- Unique page titles and meta descriptions ✓ (static product pages)
- Internal linking between pages ✓ (static pages link to shop/blog/home)
sitemap.xml Generation
The gen_sitemap.sh script now includes:
- Static pages (home, shop, about, faq, blog, reviews, track)
- All 5 blog posts
- Tools page (peptide-calculator)
- All 137 product pages
- SEO resources (llms.txt, rss.xml)
Cron: 0 3 * * * /root/polypeptides/deploy/gen_sitemap.sh
index_enhanced.html
Mounted at /tmp/index_enhanced.html:/usr/share/nginx/html/index.html:ro. Contains:
- Optimized title tag (with Canada-focused keywords)
- Meta description (Canada-focused)
- Canonical URL
- OG + Twitter Card tags (including og:image)
- RSS autodiscovery link
- Favicon link tags (ico, svg, png, apple-touch-icon)
- Analytics JS script tag
- No inline JSON-LD (prerender injects it for bots)
Icon tags in <head>:
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:image" content="https://polypeptides.store/og-image.png" />
Shipping Text
Shipping claims changed from "2–3 business days" to "approximately 1 week" across these source files (requires rebuild):
src/components/CanadaShippingBanner.tsxsrc/pages/About.tsxsrc/pages/Checkout.tsxsrc/pages/FAQ.tsx
Reviews
All fake reviews removed. Files changed (requires rebuild):
src/data/reviews.ts— replaced with empty arrays and honest zero-return functionssrc/components/Reviews.tsx— replaced with Trustpilot-only CTAsrc/components/product/ProductReviews.tsx— auto-shows "No reviews yet" for all products
The /reviews page itself (src/pages/Reviews.tsx) was already clean — just Trustpilot links.
Backlink Strategy
A comprehensive backlink opportunities list is at references/backlink-opportunities.md covering:
- Chemical vendor directories (Guidechem, ChemicalBook, LookChem, ChemIndustry)
- Academic databases (ChemSpider, PubChem, DrugBank, ChEMBL)
- Community forums (LongeCity, r/Nootropics wiki)
- GitHub curated lists (Awesome Nootropics)
- Social profiles for sameAs schema (Twitter, GitHub, LinkedIn)
Current backlink sources active:
- PubChem: 94/137 products linked (nih.gov, DR 92)
- sameAs: twitter.com, github.com in Organization schema
- chemindustry.com: Directory submission form accessible
The peptide MW calculator tool at /tools/peptide-calculator doubles as link-bait — tools pages naturally attract backlinks from researchers and other sites.
Traffic & Log Analysis
There is NO persistent access log file on disk. All access logs are in Docker stdout.
Where logs live
| Service | Log Location | Format | What's logged |
|---|---|---|---|
| Caddy | docker logs deploy-caddy-1 |
JSON | WARN/ERROR only (no log directive in Caddyfile) |
| Nginx (web) | docker logs deploy-web-1 |
Combined format | ALL requests (access.log → /dev/stdout symlink) |
Caddy does NOT log successful requests — the Caddyfile only has reverse_proxy blocks, no log directive. Only errors and ACME renewal info appear. To enable access logging, add log to the Caddyfile.
Nginx access.log is a symlink to /dev/stdout, not a real file. That means:
docker cp deploy-web-1:/var/log/nginx/access.log ...HANGS (reads from stdout pipe)docker exec deploy-web-1 cat /var/log/nginx/access.logHANGS- Use
docker logs deploy-web-1instead
Nginx log format
172.18.0.6 - - [29/May/2026:14:38:10 +0000] "GET / HTTP/1.1" 200 1387 "-" "User-Agent" "REAL_CLIENT_IP"
- Field 1: docker internal IP (172.18.0.x, ignore)
- Field 7: path
- Field 9: status code
- Field 10: response size (bytes)
- Last quoted field: real client IP (via X-Forwarded-For from Caddy)
Analyzing traffic (torsocks-safe pattern)
docker logs through torsocks SSH works for small outputs (~400 lines) but times out for larger ones. Use this safe 3-step pattern:
Step 1 — Save logs to file on VPS:
docker logs deploy-web-1 2>/dev/null > /tmp/web_logs.txt
Step 2 — Write analysis script to VPS, run it, get summary only:
# analysis script template — see references/traffic-analyzer.py
Step 3 — Read the short summary back (not the full log).
A reusable traffic analysis script is at references/traffic-analyzer.py.
Quick traffic snapshot (one-liner)
# Total requests + unique IPs
docker logs deploy-web-1 2>/dev/null | wc -l
docker logs deploy-web-1 2>/dev/null | awk '{print $NF}' | tr -d '"' | sort -u | wc -l
# Page hits only (no assets)
docker logs deploy-web-1 2>/dev/null | grep -v '\.\(js\|css\|png\|jpg\|ico\|svg\|woff\)' | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
Prerender Service (SPA SEO)
The site is a Vite SPA — all content is JS-rendered. Search engine bots see <div id="root"></div> (1,387 bytes). The prerender service intercepts bot requests and returns fully rendered HTML with all content visible.
Architecture
Googlebot → Caddy (detects bot UA) → Prerender (Node+Puppeteer) → returns 30-74KB HTML
Normal user → Caddy → Nginx → SPA (unchanged, 1,387 bytes)
Service details
- Location:
/opt/prerender/server.js - Port: 3000 (listens on
0.0.0.0) - Manager: systemd (
prerender.service) - Cache: In-memory, 1-hour TTL, max 200 entries
- Chromium: Puppeteer-bundled (
/root/.cache/puppeteer/chrome/) - Security: Blocks non-polypeptides.store URLs, no-sandbox Chromium args
Management
# Status
systemctl status prerender
curl http://localhost:3000/health
# Restart
systemctl restart prerender
# View logs
journalctl -u prerender --tail=20 -f
# Clear render cache
curl -X POST http://localhost:3000/clear-cache
# Manual test render
curl -s 'http://localhost:3000/render?url=https://polypeptides.store/' | wc -c
# Should return ~30,000+ (not 1,387)
Caddy bot routing
The Caddyfile at /root/polypeptides/deploy/Caddyfile detects 15+ bot UAs via Caddy's expression matcher and reverse-proxies them to the prerender service at 172.18.0.1:3000 (the Docker compose network gateway — NOT 172.17.0.1 which is the default bridge). The rewrite directive transforms the original request path into /render?url={scheme}://{host}{uri} before proxying.
@bot {
expression {header.User-Agent}.matches('(?i).*(googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebookexternalhit|twitterbot|linkedinbot|embedly|pinterest|rogerbot|semrushbot|ahrefsbot|dotbot|mj12bot).*')
}
handle @bot {
rewrite * /render?url={scheme}://{host}{uri}
reverse_proxy 172.18.0.1:3000
}
Bot UAs detected: googlebot, bingbot, slurp, duckduckbot, baiduspider, yandexbot, facebookexternalhit, twitterbot, linkedinbot, embedly, pinterest, rogerbot, semrushbot, ahrefsbot, dotbot, mj12bot, chatgpt, gptbot, claude, anthropic, perplexity, ccbot.
Critical: Do NOT use header_regexp — it fails with "wrong argument count or unexpected line ending" in Caddy v2. Use expression with {header.User-Agent}.matches(...) instead. The (?i) flag is needed for case-insensitive matching.
Normal traffic falls through to the SPA unchanged.
Initial setup (if rebuilding from scratch)
# 1. Install Node.js (prebuilt binary)
curl -sL https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz -o /tmp/node.tar.xz
tar -xf /tmp/node.tar.xz -C /usr/local/ --strip-components=1
# 2. Install Chromium system dependencies
apt-get install -y libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 libgbm1 \
libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 \
libpango-1.0-0 libcairo2 libasound2t64
# 3. Create service directory and install npm packages
mkdir -p /opt/prerender && cd /opt/prerender
npm init -y
npm install puppeteer express
# 4. Copy server.js from references/prerender-server.js
# 5. Create systemd service file at /etc/systemd/system/prerender.service
# 6. systemctl daemon-reload && systemctl enable --now prerender
The full server.js source and systemd unit file template are at references/prerender-server.js and references/prerender.service.
Verifying bots get prerendered content
# SPA shell (normal user)
curl -s https://polypeptides.store/ | wc -c
# → ~1,387
# Prerendered (bot)
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/ | wc -c
# → ~30,000+
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/product/bromantane | wc -c
# → ~74,000+
# Check for content
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/product/bromantane | grep -c Bromantane
# → should be >0
What bots see
Product pages render with: chemical name, CAS number, molecular formula, molecular weight, purity, category, navigation links, price-match guarantee text. The module script tags are stripped (bots don't need JS). CSS is inlined by Vite in the <head>.
Additionally, the prerender injects JSON-LD structured data:
- Product pages: Organization + WebSite + Product + BreadcrumbList schemas
- Homepage: Organization + WebSite (with SearchAction) schemas
- All pages: Organization + WebSite baseline
Product schema includes price, availability, image, and category. SearchAction enables the site search box in Google results.
Tools (Static HTML via Caddy)
Static tool pages are served from /var/www/seo/tools/ BEFORE the SPA catch-all, same pattern as blog posts. No database, no rebuild.
Caddy route
handle /tools/* {
root * /var/www/seo
file_server
}
Current tools
/tools/peptide-calculator— Peptide Molecular Weight Calculator (12.5KB). Calculates monoisotopic MW, pI, net charge at pH 7, extinction coefficient, and amino acid composition. Includes JSON-LD SoftwareApplication schema. Self-contained HTML with dark theme. Built as link-bait — tools pages attract backlinks naturally.
Adding a new tool
Same pattern as blog posts: drop a file at /var/www/seo/tools/<slug> with NO .html extension. Update gen_sitemap.sh to include the tool URL. Include JSON-LD schema appropriate to the tool type (SoftwareApplication, WebApplication).
Blog post template
All blog posts follow a consistent dark theme (#0f172a background, Inter font, #38bdf8 links, #e2e8f0 body text). A reusable template is at templates/blog-post.html. Template pattern:
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>...</title><meta name="description" content="...">
<link rel="canonical" href="https://polypeptides.store/blog/[slug]">
<meta property="og:title" content="..."><meta property="og:description" content="...">
<style>body{font-family:Inter,system-ui,sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#0f172a;color:#e2e8f0;line-height:1.7}h1{color:#f8fafc}h2{color:#cbd5e1;margin-top:28px}a{color:#38bdf8}table{width:100%;border-collapse:collapse}th,td{border:1px solid #334155;padding:8px 12px;text-align:left}th{background:#1e293b}code{background:#1e293b;padding:2px 6px;border-radius:4px}</style>
</head><body>...</body></html>
Each post MUST include:
- JSON-LD Article schema + FAQPage schema (in
<script type="application/ld+json">before</body>) - Canonical URL
- OG meta tags
- Internal links to other blog posts and relevant product pages
Adding a new blog post
- Create the HTML file from the template (
templates/blog-post.html):
# /var/www/seo/blog/<slug> (NO .html extension — Caddy file_server doesn't auto-append)
cat > /var/www/seo/blog/my-new-post << 'EOF'
<!DOCTYPE html>...content...</html>
EOF
Update the blog index (
/var/www/seo/blog/index) to add the new article listing.Update the sitemap generator — add the slug to the
for slug in [...]list ingen_sitemap.sh.Update the RSS generator — add the post to the
blog_postslist ingen_rss.sh.Regenerate:
/root/polypeptides/deploy/gen_sitemap.shNo Caddy reload needed —
file_serverpicks up new files immediately.
Current blog posts (5)
/blog/coa-purity-guide— "How to Read a Certificate of Analysis (COA)"/blog/bromantane-guide— "Bromantane: Complete Guide"/blog/research-peptides-guide— "Research Peptides: A Comprehensive Guide"/blog/peptide-storage-guide— "Peptide Storage and Handling Guide" (June 3, 2026)/blog/glp1-comparison— "Semaglutide vs Tirzepatide vs Retatrutide" (June 3, 2026)/blog/— index page listing all 5 posts
PubChem CIDs (Backlinks)
94 of 137 products have PubChem Compound IDs populated in the products.pubchem_cid column (69% coverage). Each PubChem page is a backlink from pubchem.ncbi.nlm.nih.gov (nih.gov domain — high authority, DR 92). PubChem auto-discovers vendors linked to CIDs. Remaining 43 products are mostly proprietary blends, very new research compounds, or multi-component mixtures not present in PubChem.
Populating CIDs
Script: /tmp/pmatch.py — reads products from DB, queries PubChem REST API by name, updates CIDs.
import urllib.request, urllib.error, json, subprocess, time
# Export products first:
# docker exec deploy-db-1 psql -U polypeptides -d polypeptides -t -A -F '|' \
# -c "SELECT id, name FROM products WHERE pubchem_cid IS NULL ORDER BY sort_order;" > /tmp/products_to_match.txt
with open('/tmp/products_to_match.txt') as f:
lines = [l.strip() for l in f if '|' in l]
for line in lines:
pid, name = line.split('|', 1)
clean = name.split('(')[0].split(' -')[0].replace(' HCl','').replace(' Acetate','').strip()
try:
q = urllib.request.quote(clean)
url = f'https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{q}/cids/JSON'
resp = urllib.request.urlopen(url, timeout=10)
data = json.loads(resp.read())
cid = data['IdentifierList']['CID'][0]
subprocess.run(['docker','exec','deploy-db-1','psql','-U','polypeptides','-d','polypeptides',
'-c', f"UPDATE products SET pubchem_cid = {cid} WHERE id = '{pid}';"], capture_output=True)
except urllib.error.HTTPError:
pass # 404 = not in PubChem
time.sleep(0.3) # rate limit: PubChem allows ~5/sec
Rate limit: 0.3s between requests. ~60s for 137 products. 65% match rate expected (novel/obscure compounds not in PubChem).
Analytics & Conversion Tracking
A custom Python analytics microservice tracks page views, sessions, referrers, devices, and conversion funnels. No extra containers — uses the existing PostgreSQL. Deployed June 2026 as a lightweight alternative to Umami (VPS had only ~100MB free RAM).
Architecture
Browser JS (/analytics.js) → POST /analytics/track → Caddy → analytics service (port 3099) → PostgreSQL
Dashboard: GET /analytics/* → Caddy (handle_path strips prefix) → analytics service → HTML dashboard
Service details
- Location:
/opt/analytics/server.py(full source archived atreferences/analytics-server.py) - Port: 3099 (internal, only accessed via Caddy)
- Manager: systemd (
polypeptides-analytics.service) - Log:
/var/log/analytics.log - DB tables:
analytics_events(raw events),analytics_sessions(aggregated) - Schema:
docker exec deploy-db-1 psql -U polypeptides -d polypeptides -c "\d analytics_events"
Quick Reference
| Endpoint | Method | Purpose |
|---|---|---|
/analytics/ |
GET | Dashboard HTML |
/analytics/api/stats |
GET | JSON stats API |
/analytics/track |
POST | Receive tracking events |
/analytics.js |
GET | Client-side tracking script (1.4KB) |
Management
# Status
systemctl status polypeptides-analytics
curl http://localhost:3099/health
# Restart
systemctl restart polypeptides-analytics
# View logs
tail -f /var/log/analytics.log
# Test tracking
curl -s -X POST http://localhost:3099/track \
-H 'Content-Type: application/json' \
-d '{"s":"test","e":"pageview","p":"/","r":"","t":"Test","pr":{},"sw":1920,"sh":1080,"l":"en","ua":"Chrome","ip":"","c":""}'
Caddy routes
# Dashboard (strip /analytics prefix before proxying)
handle_path /analytics/* {
reverse_proxy 172.18.0.1:3099
}
# Tracking script (static file from SEO dir)
handle /analytics.js {
root * /var/www/seo
file_server
header Content-Type application/javascript; charset=utf-8
}
iptables
The analytics service runs on the VPS host (outside Docker). Caddy containers need access to port 3099:
iptables -I DOCKER-USER 1 -p tcp -s 172.18.0.0/16 -d 172.18.0.1 --dport 3099 -j ACCEPT
iptables -I INPUT 1 -p tcp -s 172.18.0.0/16 -d 172.18.0.1 --dport 3099 -j ACCEPT
Tracking script
Served at /var/www/seo/analytics.js. Injected into index_enhanced.html via:
<script src="/analytics.js" async defer></script>
The script auto-tracks:
- All page views (including SPA navigation via
history.pushStatemonkey-patch) - Product page views (
view_productevent when path starts with/product/) - Session ID persisted in localStorage
Custom events via global window.patrack(eventName, props):
// Add to cart button
window.patrack('add_to_cart', {product: 'bromantane', price: '31.49'});
// Checkout started
window.patrack('begin_checkout', {items: 3, total: '94.47'});
// Purchase completed
window.patrack('purchase', {order: 'ORD-123', total: '94.47'});
Dashboard
Accessible at https://polypeptides.store/analytics/. Shows:
- Visitors today / new today / events today / weekly totals
- 4-step conversion funnel (view_product → add_to_cart → begin_checkout → purchase) with drop-off rates
- Top pages today with bar chart
- Referrer sources with bar chart
- Device breakdown (desktop/tablet/mobile)
- Country breakdown
- Recent sessions table (entry page, source, device, browser, page count)
- Auto-refreshes every 30 seconds
DB schema
Two tables in the polypeptides database:
analytics_events — raw event log:
id SERIAL PRIMARY KEY,session_id TEXT,event_type TEXT,path TEXT,referrer TEXT,title TEXT,properties JSONB,screen_width/height INTEGER,language TEXT,user_agent TEXT,ip_address TEXT,country TEXT,created_at TIMESTAMPTZ
analytics_sessions — aggregated per session:
session_id TEXT PRIMARY KEY,first_seen/last_seen TIMESTAMPTZ,page_views INTEGER,entry_page/exit_page TEXT,referrer TEXT,country TEXT,device_type TEXT,browser TEXT,os TEXT,duration_seconds INTEGER
Pitfalls
- Stats API is slow over torsocks: The stats endpoint runs multiple
docker exec psqlcalls. Through torsocks SSH, this sequence can exceed the 15s timeout. Test analytics locally on the VPS (curl localhost:3099/api/stats) rather than through the torsocks chain. - Duplicate instances: If
systemctl restarttimes out over torsocks, the old process may not be killed. This produces "Address already in use" errors. Fix:pkill -f 'python3 /opt/analytics/server.py'thensystemctl start polypeptides-analytics. - Conversion events require app code: Only
view_productfires automatically.add_to_cart,begin_checkout, andpurchasemust be wired up in the React app's cart/checkout/payment handlers usingwindow.patrack().
Favicon & Social Images
The site icon uses the Polymarket favicon (deployed June 2026). Source: polymarket.com/favicon.ico. The favicon is NOT the site's original logo — it's intentionally the Polymarket icon.
Current favicon
Pulled from https://polymarket.com/favicon.ico (1,248 bytes, 93×116 PNG inside ICO container). Deployed after every rebuild.
Files deployed
| File | Location | Source | Purpose |
|---|---|---|---|
favicon.ico |
/usr/share/nginx/html/ (web container) + /var/www/seo/ |
polymarket.com | Multi-res 16×16, 32×32, 48×48 |
favicon.svg |
/usr/share/nginx/html/ (web container) + /var/www/seo/ |
Hand-crafted | PM monogram on dark background |
favicon-32x32.png |
/usr/share/nginx/html/ (web container) + /var/www/seo/ |
polymarket.com | PNG fallback |
apple-touch-icon.png |
/usr/share/nginx/html/ (web container) + /var/www/seo/ |
polymarket.com | 180×180 for iOS/Safari |
og-image.png |
/var/www/seo/ |
Site logo | 1200×630 Open Graph + Twitter card (27KB) |
All favicon files are served by both Caddy (via /var/www/seo/ routes) and nginx (via the web container). Caddy is the primary server — it intercepts /favicon.* and /apple-touch-icon.png BEFORE the SPA fallback and serves with correct Content-Type headers.
Link tags (in /tmp/index_enhanced.html)
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon.svg" color="#2563eb" />
<meta property="og:image" content="https://polypeptides.store/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:image" content="https://polypeptides.store/og-image.png" />
Caddy routes for favicon files
handle /favicon.svg {
root * /var/www/seo
file_server
header Content-Type image/svg+xml
}
handle /favicon.ico {
root * /var/www/seo
file_server
header Content-Type image/x-icon
}
handle /favicon-32x32.png {
root * /var/www/seo
file_server
header Content-Type image/png
}
handle /apple-touch-icon.png {
root * /var/www/seo
file_server
header Content-Type image/png
}
CRITICAL: Restore favicon after every web rebuild
After docker compose build --no-cache web, the container is recreated from scratch — all docker cp'd files are lost. Including the Polymarket favicon. You MUST restore it after every rebuild:
# 1. Pull the Polymarket favicon
curl -sL -o /tmp/pm_favicon.ico 'https://polymarket.com/favicon.ico'
# 2. Convert to all needed formats (ImageMagick)
convert /tmp/pm_favicon.ico -gravity center -extent 93x93 /tmp/pm_square.png
convert /tmp/pm_square.png -resize 16x16 /tmp/pm16.png
convert /tmp/pm_square.png -resize 32x32 /tmp/pm32.png
convert /tmp/pm_square.png -resize 48x48 /tmp/pm48.png
convert /tmp/pm16.png /tmp/pm32.png /tmp/pm48.png /tmp/favicon.ico
cp /tmp/pm32.png /tmp/favicon-32x32.png
convert /tmp/pm_square.png -resize 180x180 /tmp/apple-touch-icon.png
# 3. Deploy to SEO dir (Caddy serves from here)
cp /tmp/favicon.ico /var/www/seo/favicon.ico
cp /tmp/favicon-32x32.png /var/www/seo/favicon-32x32.png
cp /tmp/apple-touch-icon.png /var/www/seo/apple-touch-icon.png
cp /tmp/favicon.svg /var/www/seo/favicon.svg
# 4. Also copy into web container (nginx fallback)
docker cp /tmp/favicon.ico deploy-web-1:/usr/share/nginx/html/favicon.ico
docker cp /tmp/favicon-32x32.png deploy-web-1:/usr/share/nginx/html/favicon-32x32.png
docker cp /tmp/apple-touch-icon.png deploy-web-1:/usr/share/nginx/html/apple-touch-icon.png
docker cp /tmp/favicon.svg deploy-web-1:/usr/share/nginx/html/favicon.svg
favicon.svg — DO NOT use text elements
The favicon.svg MUST use pure SVG paths (no <text> elements). Browsers render text-based SVG favicons inconsistently — system font fallback can pick up emoji variants, showing a "P emoji" instead of the intended icon. The current SVG is a simple "PM" monogram using dark background (#1a1a2e) with green accent (#00ff88).
OG image
handle /og-image.png {
root * /var/www/seo
file_server
header Content-Type image/png
header Cache-Control "public, ma
…(truncated)