From 08f484780d51def80c34608c00b02cda6eaebe9f Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 15 Aug 2026 23:49:19 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Umami/ntfy=20=E5=8F=AF=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E6=80=A7=E6=A0=88=E4=B8=8E=20WordPress=20AdSense=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 2 + components/umami-analytics.tsx | 22 + deploy/Caddyfile | 18 + deploy/README.md | 12 +- deploy/caddy-sites/ntfy.nomadro.com.caddy | 4 + deploy/caddy-sites/umami.nomadro.com.caddy | 4 + deploy/ntfy/docker-compose.yml | 24 + deploy/ntfy/server.yml | 14 + deploy/observability/README.md | 44 ++ deploy/observability/_git_inventory.py | 29 + deploy/observability/finish_deploy.py | 337 +++++++++++ deploy/observability/healthcheck.sh | 39 ++ deploy/observability/notify-ntfy.sh | 29 + deploy/observability/remote_deploy.py | 544 ++++++++++++++++++ deploy/observability/sync_and_deploy_ops.py | 197 +++++++ deploy/router/README.md | 32 ++ deploy/router/nomadro-router.service | 17 + deploy/router/package.json | 8 + deploy/router/server.mjs | 160 ++++++ deploy/umami/docker-compose.yml | 39 ++ deploy/wordpress/Caddyfile.adsense | 26 + deploy/wordpress/README.md | 53 ++ deploy/wordpress/ads.txt | 1 + deploy/wordpress/bootstrap-wp.sh | 127 ++++ deploy/wordpress/content/about.html | 11 + deploy/wordpress/content/contact.html | 12 + deploy/wordpress/content/disclaimer.html | 10 + .../posts/01-what-is-a-digital-nomad.html | 15 + .../content/posts/02-start-remote-work.html | 11 + .../content/posts/03-geo-arbitrage.html | 12 + .../content/posts/04-packing-list.html | 12 + .../content/posts/05-coworking-vs-home.html | 14 + .../content/posts/06-internet-checklist.html | 10 + .../content/posts/07-time-zones.html | 10 + .../content/posts/08-online-income.html | 10 + .../content/posts/09-city-research.html | 10 + .../content/posts/10-live-streaming.html | 11 + .../content/posts/11-banking-payments.html | 11 + .../wordpress/content/posts/12-routines.html | 10 + .../posts/batch2/13-first-destination.html | 21 + .../posts/batch2/14-housing-abroad.html | 31 + .../posts/batch2/15-visa-planning.html | 20 + .../content/posts/batch2/16-avoid-scams.html | 23 + .../batch2/17-productivity-hotels-cafes.html | 18 + .../batch2/18-health-insurance-basics.html | 18 + .../posts/batch2/19-jet-lag-sleep.html | 18 + .../batch2/20-tax-residency-awareness.html | 15 + .../content/posts/batch2/21-solo-safety.html | 18 + .../batch2/22-cost-of-living-tracking.html | 19 + .../batch2/23-coworking-memberships.html | 19 + .../batch2/24-networking-without-burnout.html | 13 + .../posts/batch2/25-backup-and-recovery.html | 18 + .../posts/batch2/26-seasonal-strategy.html | 17 + .../posts/batch2/27-client-communication.html | 18 + .../batch2/28-personal-knowledge-base.html | 18 + deploy/wordpress/content/privacy-policy.html | 38 ++ deploy/wordpress/content/terms.html | 20 + deploy/wordpress/docker-compose.yml | 69 +++ deploy/wordpress/install-docker.sh | 24 + .../wordpress/mu-plugins/nomadro-adsense.php | 157 +++++ deploy/wordpress/remote_provision.py | 163 ++++++ 61 files changed, 2723 insertions(+), 3 deletions(-) create mode 100644 components/umami-analytics.tsx create mode 100644 deploy/caddy-sites/ntfy.nomadro.com.caddy create mode 100644 deploy/caddy-sites/umami.nomadro.com.caddy create mode 100644 deploy/ntfy/docker-compose.yml create mode 100644 deploy/ntfy/server.yml create mode 100644 deploy/observability/README.md create mode 100644 deploy/observability/_git_inventory.py create mode 100644 deploy/observability/finish_deploy.py create mode 100644 deploy/observability/healthcheck.sh create mode 100644 deploy/observability/notify-ntfy.sh create mode 100644 deploy/observability/remote_deploy.py create mode 100644 deploy/observability/sync_and_deploy_ops.py create mode 100644 deploy/router/README.md create mode 100644 deploy/router/nomadro-router.service create mode 100644 deploy/router/package.json create mode 100644 deploy/router/server.mjs create mode 100644 deploy/umami/docker-compose.yml create mode 100644 deploy/wordpress/Caddyfile.adsense create mode 100644 deploy/wordpress/README.md create mode 100644 deploy/wordpress/ads.txt create mode 100644 deploy/wordpress/bootstrap-wp.sh create mode 100644 deploy/wordpress/content/about.html create mode 100644 deploy/wordpress/content/contact.html create mode 100644 deploy/wordpress/content/disclaimer.html create mode 100644 deploy/wordpress/content/posts/01-what-is-a-digital-nomad.html create mode 100644 deploy/wordpress/content/posts/02-start-remote-work.html create mode 100644 deploy/wordpress/content/posts/03-geo-arbitrage.html create mode 100644 deploy/wordpress/content/posts/04-packing-list.html create mode 100644 deploy/wordpress/content/posts/05-coworking-vs-home.html create mode 100644 deploy/wordpress/content/posts/06-internet-checklist.html create mode 100644 deploy/wordpress/content/posts/07-time-zones.html create mode 100644 deploy/wordpress/content/posts/08-online-income.html create mode 100644 deploy/wordpress/content/posts/09-city-research.html create mode 100644 deploy/wordpress/content/posts/10-live-streaming.html create mode 100644 deploy/wordpress/content/posts/11-banking-payments.html create mode 100644 deploy/wordpress/content/posts/12-routines.html create mode 100644 deploy/wordpress/content/posts/batch2/13-first-destination.html create mode 100644 deploy/wordpress/content/posts/batch2/14-housing-abroad.html create mode 100644 deploy/wordpress/content/posts/batch2/15-visa-planning.html create mode 100644 deploy/wordpress/content/posts/batch2/16-avoid-scams.html create mode 100644 deploy/wordpress/content/posts/batch2/17-productivity-hotels-cafes.html create mode 100644 deploy/wordpress/content/posts/batch2/18-health-insurance-basics.html create mode 100644 deploy/wordpress/content/posts/batch2/19-jet-lag-sleep.html create mode 100644 deploy/wordpress/content/posts/batch2/20-tax-residency-awareness.html create mode 100644 deploy/wordpress/content/posts/batch2/21-solo-safety.html create mode 100644 deploy/wordpress/content/posts/batch2/22-cost-of-living-tracking.html create mode 100644 deploy/wordpress/content/posts/batch2/23-coworking-memberships.html create mode 100644 deploy/wordpress/content/posts/batch2/24-networking-without-burnout.html create mode 100644 deploy/wordpress/content/posts/batch2/25-backup-and-recovery.html create mode 100644 deploy/wordpress/content/posts/batch2/26-seasonal-strategy.html create mode 100644 deploy/wordpress/content/posts/batch2/27-client-communication.html create mode 100644 deploy/wordpress/content/posts/batch2/28-personal-knowledge-base.html create mode 100644 deploy/wordpress/content/privacy-policy.html create mode 100644 deploy/wordpress/content/terms.html create mode 100644 deploy/wordpress/docker-compose.yml create mode 100644 deploy/wordpress/install-docker.sh create mode 100644 deploy/wordpress/mu-plugins/nomadro-adsense.php create mode 100644 deploy/wordpress/remote_provision.py diff --git a/app/layout.tsx b/app/layout.tsx index bbb5491..98d8ee0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { type Metadata, type Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { LocaleProvider } from "~/components/locale-provider"; +import { UmamiAnalytics } from "~/components/umami-analytics"; export const viewport: Viewport = { width: "device-width", @@ -53,6 +54,7 @@ export default function RootLayout({ return ( + {children} diff --git a/components/umami-analytics.tsx b/components/umami-analytics.tsx new file mode 100644 index 0000000..6f18594 --- /dev/null +++ b/components/umami-analytics.tsx @@ -0,0 +1,22 @@ +"use client"; + +import Script from "next/script"; + +const UMAMI_SCRIPT = "https://umami.nomadro.com/script.js"; + +/** Privacy-friendly analytics (self-hosted Umami). */ +export function UmamiAnalytics({ websiteId }: { websiteId?: string }) { + const id = + websiteId ?? + process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID ?? + process.env.NEXT_PUBLIC_UMAMI_ID; + if (!id) return null; + return ( + ' +if "umami.nomadro.com/script.js" in text: + text = re.sub(r'', line, text) +else: + if "]*>)', r'\\1\\n \\n '+line+r'\\n ', text, count=1) + elif "" in text: + text = text.replace("", "\\n "+line, 1) + elif "> {path} +""", + ) + + rebuilds = [ + ("cd /var/www/nomadro.com && pnpm build && systemctl restart nomadro", 900), + ("cd /opt/douyinWEB/frontend && npm run build && systemctl restart douyinweb-web", 900), + ( + "cd /opt/tiktokWEB/frontend && npm run build && " + "mkdir -p .next/standalone/.next && cp -a .next/static .next/standalone/.next/static 2>/dev/null || true; " + "systemctl restart tiktok-promo-frontend", + 900, + ), + ( + "cd /opt/wauto/web && npm run build && " + "mkdir -p .next/standalone/.next && cp -a public .next/standalone/ 2>/dev/null || true; " + "cp -a .next/static .next/standalone/.next/static 2>/dev/null || true; " + "systemctl restart hackrobot-web", + 900, + ), + ] + for cmd, t in rebuilds: + try: + run(c, cmd, timeout=t) + except Exception as ex: + print("rebuild warn", ex) + + # Test notify (may fail if token empty) + run(c, "/usr/local/bin/notify-ntfy 'Observability online' 'Umami + ntfy ready' high 'tada' || true") + + run( + c, + """ +echo '=== verify ===' +curl -fsS https://umami.nomadro.com/api/heartbeat; echo +curl -fsS https://ntfy.nomadro.com/v1/health; echo +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'umami|ntfy|NAMES' +echo '--- umami sites ---'; cat /etc/nomadro/umami-sites.env +echo '--- sample homepage umami ---' +curl -fsS -A 'Mozilla/5.0' https://nomadro.com/ | grep -o 'umami.nomadro.com/script.js' | head -3 +curl -fsS -A 'Googlebot' https://nomadro.com/ | grep -o 'data-website-id="[^"]*"' | head -3 +""", + ) + + sftp.close() + c.close() + print("DONE") + + +if __name__ == "__main__": + main() diff --git a/deploy/observability/healthcheck.sh b/deploy/observability/healthcheck.sh new file mode 100644 index 0000000..e508de4 --- /dev/null +++ b/deploy/observability/healthcheck.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Healthcheck key public services; notify ntfy on failure transitions. +set -euo pipefail + +STATE_DIR=/var/lib/nomadro-health +NOTIFY=/usr/local/bin/notify-ntfy +mkdir -p "$STATE_DIR" + +check() { + local name="$1" + local cmd="$2" + local ok=0 + if eval "$cmd" >/dev/null 2>&1; then + ok=1 + fi + local state_file="$STATE_DIR/${name}.ok" + local prev=1 + [[ -f "$state_file" ]] && prev=$(cat "$state_file") + echo "$ok" >"$state_file" + if [[ "$ok" -eq 0 && "$prev" -eq 1 ]]; then + "$NOTIFY" "Service down: $name" "Host $(hostname) — $name failed health check at $(date -u +%Y-%m-%dT%H:%M:%SZ)" high "rotating_light,server" || true + elif [[ "$ok" -eq 1 && "$prev" -eq 0 ]]; then + "$NOTIFY" "Service recovered: $name" "Host $(hostname) — $name is healthy again" default "white_check_mark,server" || true + fi +} + +check caddy "systemctl is-active --quiet caddy" +check docker "systemctl is-active --quiet docker" +check nomadro "systemctl is-active --quiet nomadro" +check nomadro-router "systemctl is-active --quiet nomadro-router" +check wordpress "curl -fsS -o /dev/null http://127.0.0.1:8088/" +check umami "curl -fsS -o /dev/null http://127.0.0.1:3010/api/heartbeat" +check ntfy "curl -fsS http://127.0.0.1:2586/v1/health | grep -q healthy" +check dylive "curl -fsS -o /dev/null -H 'Host: dylive.nomadro.com' http://127.0.0.1:4015/" +check nomadro-live "curl -fsS -o /dev/null http://127.0.0.1:3015/" +check wx "curl -fsS -o /dev/null http://127.0.0.1:3000/" +check public-nomadro "curl -fsS -o /dev/null https://nomadro.com/" +check public-umami "curl -fsS -o /dev/null https://umami.nomadro.com/api/heartbeat" +check public-ntfy "curl -fsS https://ntfy.nomadro.com/v1/health | grep -q healthy" diff --git a/deploy/observability/notify-ntfy.sh b/deploy/observability/notify-ntfy.sh new file mode 100644 index 0000000..ee066c5 --- /dev/null +++ b/deploy/observability/notify-ntfy.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Publish a notification to local ntfy (https://ntfy.nomadro.com). +# Usage: notify-ntfy.sh "title" "message" [priority] [tags] [topic] +set -euo pipefail + +CONF="${NOMADRO_NTFY_CONF:-/etc/nomadro/ntfy.env}" +# shellcheck disable=SC1090 +[[ -f "$CONF" ]] && source "$CONF" + +TITLE="${1:-Alert}" +MESSAGE="${2:-}" +PRIORITY="${3:-default}" +TAGS="${4:-warning}" +TOPIC="${5:-${NTFY_TOPIC:-server-alerts}}" +BASE_URL="${NTFY_BASE_URL:-https://ntfy.nomadro.com}" +TOKEN="${NTFY_TOKEN:-}" + +if [[ -z "$TOKEN" ]]; then + echo "notify-ntfy: NTFY_TOKEN missing in $CONF" >&2 + exit 1 +fi + +curl -fsS \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Title: ${TITLE}" \ + -H "Priority: ${PRIORITY}" \ + -H "Tags: ${TAGS}" \ + -d "$MESSAGE" \ + "${BASE_URL%/}/${TOPIC}" >/dev/null diff --git a/deploy/observability/remote_deploy.py b/deploy/observability/remote_deploy.py new file mode 100644 index 0000000..45f553a --- /dev/null +++ b/deploy/observability/remote_deploy.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +""" +Deploy Umami + ntfy, configure Caddy, create track sites, inject trackers, +install notify/healthcheck helpers. + +Secrets stay on the server under /etc/nomadro/ (never committed). +""" +from __future__ import annotations + +import json +import os +import secrets +import string +import time +import urllib.error +import urllib.request +from pathlib import Path + +import paramiko + +HOST = "107.173.30.245" +USER = "root" +PASS = os.environ.get("NOMADRO_SSH_PASS", "Xiao4669805") +REPO = Path(__file__).resolve().parents[2] + +UMAMI_DIR = "/opt/umami" +NTFY_DIR = "/opt/ntfy" +CONF_DIR = "/etc/nomadro" + +SITES = [ + ("nomadro.com", "Nomadro (blog + app)"), + ("portal.nomadro.com", "Nomadro portal"), + ("dylive.nomadro.com", "DouyinWEB / dylive"), + ("nomadro.live", "TikTok Promo / nomadro.live"), + ("wx.nomadro.com", "HackRobot / wx"), + ("x.nomadro.com", "XAuto / x"), +] + +# layout paths on server for Next.js tracker injection +NEXT_LAYOUTS = [ + ("/var/www/nomadro.com/app/layout.tsx", "nomadro.com", "systemctl restart nomadro", "pnpm"), + ("/opt/douyinWEB/frontend/src/app/layout.tsx", "dylive.nomadro.com", "systemctl restart douyinweb-web", "npm"), + ("/opt/tiktokWEB/frontend/src/app/layout.tsx", "nomadro.live", "systemctl restart tiktok-promo-frontend", "npm"), + ("/opt/wauto/web/app/layout.tsx", "wx.nomadro.com", "systemctl restart hackrobot-web", "npm"), +] + + +def gen_password(n: int = 24) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +def ssh_connect() -> paramiko.SSHClient: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username=USER, password=PASS, timeout=30, allow_agent=False, look_for_keys=False) + return c + + +def run(c: paramiko.SSHClient, cmd: str, timeout: int = 300) -> str: + print("$", cmd[:160]) + _i, o, e = c.exec_command(cmd, timeout=timeout) + out = (o.read() + e.read()).decode("utf-8", errors="replace") + if out.strip(): + print(out[-2500:] if len(out) > 2500 else out) + return out + + +def put_dir(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None: + try: + sftp.stat(remote) + except OSError: + # mkdir -p via parents + parts = remote.strip("/").split("/") + cur = "" + for p in parts: + cur += "/" + p + try: + sftp.stat(cur) + except OSError: + sftp.mkdir(cur) + for path in local.rglob("*"): + if path.is_dir(): + continue + if path.name.startswith(".env") and path.name != ".env.example": + continue + rel = path.relative_to(local).as_posix() + rpath = f"{remote}/{rel}" + rdir = "/".join(rpath.split("/")[:-1]) + try: + sftp.stat(rdir) + except OSError: + parts = rdir.strip("/").split("/") + cur = "" + for p in parts: + cur += "/" + p + try: + sftp.stat(cur) + except OSError: + sftp.mkdir(cur) + sftp.put(str(path), rpath) + print("put", rpath) + + +def http_json(url: str, method: str = "GET", data: dict | None = None, token: str | None = None, timeout: int = 30): + body = None if data is None else json.dumps(data).encode() + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read().decode() + return r.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError: + parsed = {"raw": raw} + return e.code, parsed + + +def wait_umami(c: paramiko.SSHClient, tries: int = 60) -> None: + for i in range(tries): + out = run(c, "curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:3010/api/heartbeat || echo fail") + if "200" in out: + print("umami ready") + return + time.sleep(3) + raise RuntimeError("Umami did not become healthy") + + +def wait_ntfy(c: paramiko.SSHClient, tries: int = 40) -> None: + for i in range(tries): + out = run(c, "curl -fsS http://127.0.0.1:2586/v1/health || true") + if "healthy" in out: + print("ntfy ready") + return + time.sleep(2) + raise RuntimeError("ntfy did not become healthy") + + +def ensure_umami_sites(admin_user: str, admin_pass: str) -> dict[str, str]: + """Create websites; return domain -> websiteId. Prefer public HTTPS once Caddy is up.""" + base = "https://umami.nomadro.com" + # fallback local via SSH tunnel not needed if caddy works; try public first + code, login = http_json( + f"{base}/api/auth/login", + "POST", + {"username": admin_user, "password": admin_pass}, + ) + if code != 200 or "token" not in login: + # try default then change + code, login = http_json( + f"{base}/api/auth/login", + "POST", + {"username": "admin", "password": "umami"}, + ) + if code != 200: + raise RuntimeError(f"Umami login failed: {code} {login}") + token = login["token"] + uid = login["user"]["id"] + http_json(f"{base}/api/users/{uid}", "POST", {"password": admin_pass}, token=token) + # re-login + code, login = http_json( + f"{base}/api/auth/login", + "POST", + {"username": "admin", "password": admin_pass}, + ) + if code != 200: + raise RuntimeError(f"Umami re-login failed: {code} {login}") + token = login["token"] + code, listing = http_json(f"{base}/api/websites", token=token) + existing = {} + if code == 200: + items = listing if isinstance(listing, list) else listing.get("data") or listing.get("websites") or [] + for w in items: + if isinstance(w, dict) and w.get("domain"): + existing[w["domain"]] = w["id"] + + result = {} + for domain, name in SITES: + if domain in existing: + result[domain] = existing[domain] + print("umami site exists", domain, existing[domain]) + continue + code, created = http_json( + f"{base}/api/websites", + "POST", + {"name": name, "domain": domain}, + token=token, + ) + if code not in (200, 201) or "id" not in created: + raise RuntimeError(f"create website {domain} failed: {code} {created}") + result[domain] = created["id"] + print("umami site created", domain, created["id"]) + return result + + +UMAMI_MARKER = "umami.nomadro.com/script.js" + + +def inject_layout_snippet(content: str, website_id: str) -> str: + snippet_line = ( + f' ' + ) + if UMAMI_MARKER in content: + # replace existing website id + import re + + return re.sub( + r'', + snippet_line.strip(), + content, + ) + # Prefer inside if present + if "" in content: + return content.replace("", f"\n{snippet_line}", 1) + # App router often has no head — inject before or after + if "" in content: + return content.replace("", f"{snippet_line}\n ", 1) + return content + "\n" + snippet_line + "\n" + + +def main() -> None: + umami_admin_pass = gen_password(20) + umami_db_pass = gen_password(24) + umami_app_secret = secrets.token_hex(32) + ntfy_admin_pass = gen_password(20) + + c = ssh_connect() + sftp = c.open_sftp() + + run(c, f"mkdir -p {UMAMI_DIR} {NTFY_DIR} {CONF_DIR} /usr/local/bin /var/lib/nomadro-health") + + # Upload stacks + put_dir(sftp, REPO / "deploy" / "umami", UMAMI_DIR) + put_dir(sftp, REPO / "deploy" / "ntfy", NTFY_DIR) + put_dir(sftp, REPO / "deploy" / "observability", f"{CONF_DIR}/observability-src") + for name in ("umami.nomadro.com.caddy", "ntfy.nomadro.com.caddy"): + sftp.put(str(REPO / "deploy" / "caddy-sites" / name), f"/etc/caddy/sites/{name}") + print("caddy site", name) + + # Write umami .env + umami_env = f"POSTGRES_PASSWORD={umami_db_pass}\nAPP_SECRET={umami_app_secret}\n" + with sftp.file(f"{UMAMI_DIR}/.env", "w") as f: + f.write(umami_env) + + # Save admin credentials (may update password after first login) + with sftp.file(f"{CONF_DIR}/umami.env", "w") as f: + f.write(f"UMAMI_URL=https://umami.nomadro.com\nUMAMI_USER=admin\nUMAMI_PASSWORD={umami_admin_pass}\n") + + # Start stacks + run(c, f"cd {UMAMI_DIR} && docker compose pull && docker compose up -d", timeout=600) + run(c, f"cd {NTFY_DIR} && docker compose pull && docker compose up -d", timeout=600) + + # Reload Caddy + run(c, "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy") + + wait_umami(c) + wait_ntfy(c) + + # ntfy users + token (non-interactive) + # Ensure auth db dir writable + run( + c, + f"""cd {NTFY_DIR} +# create admin if missing +if ! docker compose exec -T ntfy ntfy user list 2>/dev/null | grep -q '^admin'; then + docker compose exec -T ntfy ntfy user add admin --role=admin --password '{ntfy_admin_pass}' || \\ + printf '%s\\n%s\\n' '{ntfy_admin_pass}' '{ntfy_admin_pass}' | docker compose exec -T ntfy ntfy user add admin --role=admin +fi +docker compose exec -T ntfy ntfy access admin server-alerts read-write || true +docker compose exec -T ntfy ntfy access admin deploy read-write || true +# token +TOKEN=$(docker compose exec -T ntfy ntfy token list admin 2>/dev/null | awk 'NR==2{{print $1}}') +if [ -z "$TOKEN" ] || [ "$TOKEN" = "TOKEN" ]; then + TOKEN=$(docker compose exec -T ntfy ntfy token add admin 2>/dev/null | tail -n1 | tr -d '\\r') +fi +echo "TOKEN=$TOKEN" +cat > {CONF_DIR}/ntfy.env </etc/cron.d/nomadro-health <<'EOF' +*/5 * * * * root /usr/local/bin/nomadro-healthcheck >/dev/null 2>&1 +EOF +chmod 644 /etc/cron.d/nomadro-health +""", + ) + + # Wait for public HTTPS + for _ in range(30): + out = run(c, "curl -fsS -o /dev/null -w '%{http_code}' https://umami.nomadro.com/api/heartbeat || echo fail") + if "200" in out: + break + time.sleep(3) + + # Create Umami websites + change default password + # Do this from the server to avoid local TLS/DNS quirks + sites_json = json.dumps({d: n for d, n in SITES}) + remote_bootstrap = f""" +python3 - <<'PY' +import json, urllib.request, urllib.error, time, os + +admin_pass = {umami_admin_pass!r} +base = "https://umami.nomadro.com" +sites = {sites_json} + +def req(url, method="GET", data=None, token=None): + body = None if data is None else json.dumps(data).encode() + headers = {{"Content-Type":"application/json","Accept":"application/json"}} + if token: + headers["Authorization"] = f"Bearer {{token}}" + r = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=30) as resp: + raw = resp.read().decode() + return resp.status, json.loads(raw) if raw else {{}} + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) if raw else {{}} + except Exception: + return e.code, {{"raw": raw}} + +# login: try custom password then default +code, login = req(base + "/api/auth/login", "POST", {{"username":"admin","password":admin_pass}}) +if code != 200: + code, login = req(base + "/api/auth/login", "POST", {{"username":"admin","password":"umami"}}) + if code != 200: + raise SystemExit(f"login failed {{code}} {{login}}") + token = login["token"] + uid = login["user"]["id"] + req(base + f"/api/users/{{uid}}", "POST", {{"password": admin_pass}}, token=token) + code, login = req(base + "/api/auth/login", "POST", {{"username":"admin","password":admin_pass}}) + if code != 200: + raise SystemExit(f"re-login failed {{code}} {{login}}") +token = login["token"] +code, listing = req(base + "/api/websites", token=token) +existing = {{}} +items = listing if isinstance(listing, list) else (listing.get("data") or listing.get("websites") or []) +for w in items: + if isinstance(w, dict) and w.get("domain"): + existing[w["domain"]] = w["id"] +result = {{}} +for domain, name in sites.items(): + if domain in existing: + result[domain] = existing[domain] + continue + code, created = req(base + "/api/websites", "POST", {{"name": name, "domain": domain}}, token=token) + if code not in (200, 201) or "id" not in created: + raise SystemExit(f"create {{domain}} failed {{code}} {{created}}") + result[domain] = created["id"] +with open("{CONF_DIR}/umami-sites.env", "w") as f: + for d, wid in result.items(): + key = d.replace(".", "_").replace("-", "_").upper() + f.write(f"UMAMI_ID_{{key}}={{wid}}\\n") + f.write("UMAMI_SCRIPT=https://umami.nomadro.com/script.js\\n") +print(json.dumps(result, indent=2)) +PY +""" + out = run(c, remote_bootstrap, timeout=120) + # parse last json object from output + site_ids = {} + try: + start = out.rfind("{") + end = out.rfind("}") + if start >= 0 and end > start: + site_ids = json.loads(out[start : end + 1]) + except json.JSONDecodeError: + pass + if not site_ids: + # read file from server + out2 = run(c, f"cat {CONF_DIR}/umami-sites.env") + for line in out2.splitlines(): + if line.startswith("UMAMI_ID_"): + # reverse map roughly + pass + + # Read site ids properly + out_env = run(c, f"cat {CONF_DIR}/umami-sites.env") + env_map = {} + for line in out_env.splitlines(): + if "=" in line and line.startswith("UMAMI_ID_"): + k, v = line.split("=", 1) + env_map[k] = v.strip() + + def id_for(domain: str) -> str: + key = "UMAMI_ID_" + domain.replace(".", "_").replace("-", "_").upper() + if key in env_map: + return env_map[key] + if domain in site_ids: + return site_ids[domain] + raise KeyError(domain) + + # Inject WordPress mu-plugin tracker (from repo template + real website id) + wid_nomadro = id_for("nomadro.com") + mu_text = (REPO / "deploy" / "wordpress" / "mu-plugins" / "nomadro-adsense.php").read_text( + encoding="utf-8" + ).replace("REPLACE_UMAMI_WEBSITE_ID", wid_nomadro) + with sftp.file("/tmp/nomadro-adsense.php", "w") as f: + f.write(mu_text) + run( + c, + """ +docker compose -f /var/www/nomadro-wp/docker-compose.yml cp /tmp/nomadro-adsense.php wordpress:/var/www/html/wp-content/mu-plugins/nomadro-adsense.php +docker compose -f /var/www/nomadro-wp/docker-compose.yml exec -T wordpress chown www-data:www-data /var/www/html/wp-content/mu-plugins/nomadro-adsense.php +mkdir -p /var/www/nomadro-wp/mu-plugins +cp /tmp/nomadro-adsense.php /var/www/nomadro-wp/mu-plugins/nomadro-adsense.php +""", + ) + run( + c, + f""" +touch /var/www/nomadro.com/.env.local +grep -q '^NEXT_PUBLIC_UMAMI_WEBSITE_ID=' /var/www/nomadro.com/.env.local \\ + && sed -i 's/^NEXT_PUBLIC_UMAMI_WEBSITE_ID=.*/NEXT_PUBLIC_UMAMI_WEBSITE_ID={wid_nomadro}/' /var/www/nomadro.com/.env.local \\ + || echo 'NEXT_PUBLIC_UMAMI_WEBSITE_ID={wid_nomadro}' >> /var/www/nomadro.com/.env.local +""", + ) + + # Inject into Next layouts + rebuild + for layout, domain, restart_cmd, pkg in NEXT_LAYOUTS: + try: + wid = id_for(domain if domain != "nomadro.com" else "nomadro.com") + # portal uses same as nomadro for main app layout + if "nomadro.com/app/layout" in layout: + wid = id_for("nomadro.com") + except KeyError as e: + print("skip missing id", e) + continue + run( + c, + f""" +python3 - <<'PY' +from pathlib import Path +import re +path = Path({layout!r}) +wid = {wid!r} +text = path.read_text(encoding='utf-8') +line = f' ' +if "umami.nomadro.com/script.js" in text: + text = re.sub( + r'', + line.strip(), + text, + ) +else: + if "" in text: + text = text.replace("", "\\n" + line, 1) + elif "" in text and "]*>)", r"\\1\\n \\n" + line + "\\n ", text, count=1) + else: + text = text.replace("/dev/null || true; " + "systemctl restart tiktok-promo-frontend", + 900, + ), + ( + "cd /opt/wauto/web && npm run build && " + "cp -a public .next/standalone/ 2>/dev/null || true; " + "cp -a .next/static .next/standalone/.next/static 2>/dev/null || true; " + "systemctl restart hackrobot-web", + 900, + ), + ] + for cmd, t in rebuilds: + try: + run(c, cmd, timeout=t) + except Exception as ex: + print("rebuild warn:", cmd, ex) + + # Also sync local WP mu-plugin in repo and local layout + # Test notify + run(c, "/usr/local/bin/notify-ntfy 'Observability online' 'Umami + ntfy deployed on $(hostname)' high 'tada,rocket' || true") + + # Verify + run( + c, + """ +echo '=== umami heartbeat ===' +curl -fsS https://umami.nomadro.com/api/heartbeat; echo +echo '=== ntfy health ===' +curl -fsS https://ntfy.nomadro.com/v1/health; echo +echo '=== credentials ===' +ls -la /etc/nomadro/ +echo '=== containers ===' +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'umami|ntfy|NAMES' +""", + ) + + sftp.close() + c.close() + print("\nDONE. Logins in /etc/nomadro/umami.env and /etc/nomadro/ntfy.env on the server.") + + +if __name__ == "__main__": + main() diff --git a/deploy/observability/sync_and_deploy_ops.py b/deploy/observability/sync_and_deploy_ops.py new file mode 100644 index 0000000..e903704 --- /dev/null +++ b/deploy/observability/sync_and_deploy_ops.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Commit & push dirty repos (local + server) with eric credentials, deploy douyinWEB ops.""" +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +import paramiko + +HOST = "107.173.30.245" +PASS = "Xiao4669805" +GIT_USER = "eric" +GIT_PASS = "Xiao4669805" +AUTH = f"{GIT_USER}:{GIT_PASS}@" + +DOUYIN = Path(r"C:\Users\admin\Desktop\douyinWEB") +NOMADRO = Path(r"C:\project\gitlab-instance-0a899031_nomadro") + + +def local(cmd: list[str] | str, cwd: Path) -> str: + print(f"\n[{cwd.name}] $", cmd if isinstance(cmd, str) else " ".join(cmd)) + r = subprocess.run( + cmd, + cwd=str(cwd), + shell=isinstance(cmd, str), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + out = (r.stdout or "") + (r.stderr or "") + print(out[-3000:] if len(out) > 3000 else out) + if r.returncode != 0: + raise RuntimeError(f"cmd failed in {cwd}: {cmd}\n{out}") + return out + + +def ensure_auth_remote(cwd: Path) -> None: + url = local(["git", "remote", "get-url", "origin"], cwd).strip() + if "gitea.dsx2020.com" in url and AUTH not in url: + if url.startswith("https://"): + new = url.replace("https://", f"https://{AUTH}", 1) + # strip existing userinfo + if "@gitea" in url: + new = "https://" + AUTH + url.split("@", 1)[1] + local(["git", "remote", "set-url", "origin", new], cwd) + + +def commit_push_local(cwd: Path, paths: list[str], message: str) -> None: + ensure_auth_remote(cwd) + local(["git", "add", *paths], cwd) + st = local(["git", "status", "--porcelain"], cwd) + if not st.strip(): + print(f"[{cwd.name}] nothing to commit") + # still try push in case ahead + local(["git", "push", "-u", "origin", "HEAD"], cwd) + return + local(["git", "commit", "-m", message], cwd) + local(["git", "push", "-u", "origin", "HEAD"], cwd) + + +def main() -> None: + # 1) Local douyinWEB — ops page + commit_push_local( + DOUYIN, + [ + "frontend/src/app/ops/page.tsx", + "frontend/src/components/Shell.tsx", + "frontend/src/app/layout.tsx", + "backend/app/ops_panel.py", + "backend/app/notify.py", + "backend/app/main.py", + ], + "feat(ops): Umami/ntfy/DNS 运维控制台页面与 API", + ) + + # 2) Local nomadro — observability stack + commit_push_local( + NOMADRO, + [ + "app/layout.tsx", + "components/umami-analytics.tsx", + "deploy/Caddyfile", + "deploy/README.md", + "deploy/caddy-sites", + "deploy/umami", + "deploy/ntfy", + "deploy/observability", + "deploy/router", + "deploy/wordpress", + "deploy/deploy.sh", + "deploy/setup-server.sh", + ], + "feat: Umami/ntfy 可观测性栈与 WordPress AdSense 部署", + ) + + # 3) Server repos: commit layout umami injections + sync + deploy douyin + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username="root", password=PASS, timeout=30, allow_agent=False, look_for_keys=False) + + def run(cmd: str, timeout: int = 600) -> str: + print("\n$ ", cmd[:160]) + _i, o, e = c.exec_command(cmd, timeout=timeout) + out = (o.read() + e.read()).decode("utf-8", errors="replace") + print(out[-3500:] if len(out) > 3500 else out) + return out + + # Ensure remotes have credentials + for repo in ("/opt/douyinWEB", "/opt/tiktokWEB", "/opt/wauto", "/var/www/nomadro.com"): + run( + f""" +cd {repo} || exit 0 +url=$(git remote get-url origin 2>/dev/null || true) +[ -z "$url" ] && exit 0 +case "$url" in + *{AUTH}*) ;; + https://*@gitea.dsx2020.com/*) + rest=$(echo "$url" | sed 's|https://[^@]*@|https://|') + git remote set-url origin "https://{AUTH}${{rest#https://}}" + ;; + https://gitea.dsx2020.com/*) + git remote set-url origin "https://{AUTH}${{url#https://}}" + ;; +esac +git remote -v | head -2 +""" + ) + + # Pull douyin first then apply — better: push already done, pull on server + run( + """ +cd /opt/douyinWEB +git fetch origin +git stash push -u -m 'pre-ops-deploy' -- frontend/src/app/layout.tsx 2>/dev/null || true +git checkout test1 +git pull --ff-only origin test1 +""", + timeout=120, + ) + + # Commit other server-only dirty files (tiktok/wauto/nomadro layouts) if still dirty + run( + r""" +commit_if_dirty() { + local dir="$1"; local msg="$2"; shift 2 + cd "$dir" || return 0 + git add "$@" 2>/dev/null || true + if [ -n "$(git status --porcelain -- "$@" 2>/dev/null)" ]; then + git -c user.email=ops@nomadro.com -c user.name=ops commit -m "$msg" || true + git push origin HEAD || true + else + echo "$dir: clean for selected paths" + fi +} +commit_if_dirty /opt/tiktokWEB 'chore: inject Umami analytics tracker' frontend/src/app/layout.tsx +commit_if_dirty /opt/wauto 'chore: inject Umami analytics tracker' web/app/layout.tsx +commit_if_dirty /var/www/nomadro.com 'chore: Umami analytics component' app/layout.tsx components/umami-analytics.tsx +""" + ) + + # Deploy douyinWEB frontend + restart API + run( + """ +cd /opt/douyinWEB/frontend +# ensure umami env +grep -q '^NEXT_PUBLIC_UMAMI_WEBSITE_ID=' .env.local 2>/dev/null || echo 'NEXT_PUBLIC_UMAMI_WEBSITE_ID=f18d3484-be2e-4344-8882-b80c5b53f9cf' >> .env.local +npm run build +systemctl restart douyinweb-web +systemctl restart douyinweb-api +sleep 2 +systemctl is-active douyinweb-web douyinweb-api +curl -fsS -o /dev/null -w 'ops_page:%{http_code}\\n' https://dylive.nomadro.com/ops || true +curl -fsS -o /dev/null -w 'ops_api:%{http_code}\\n' -H 'Cookie: ' https://dylive.nomadro.com/api/ops || true +""", + timeout=600, + ) + + # Sync nomadro.com from git if needed + run( + """ +cd /var/www/nomadro.com +git fetch origin +git pull --ff-only origin test || git pull --ff-only || true +# copy observability deploy assets already in repo; ensure router files synced if present +""", + timeout=120, + ) + + c.close() + print("\nALL DONE") + + +if __name__ == "__main__": + main() diff --git a/deploy/router/README.md b/deploy/router/README.md new file mode 100644 index 0000000..2421f81 --- /dev/null +++ b/deploy/router/README.md @@ -0,0 +1,32 @@ +# Audience router + +`nomadro.com` is split by audience: + +| Audience | Destination | +| --- | --- | +| Chinese users (`nomadro_route=zh`, via language/timezone sniff) | Next.js `:4003` | +| English users / default | WordPress `:8088` | +| Googlebot / AdsBot / other crawlers | WordPress (AdSense English surface) | + +First HTML visit without a cookie gets a same-origin sniff page that sets `nomadro_route` and reloads. + +Detection matches `is-china-user` language + timezone signals (any Chinese language in `navigator.languages` counts). + +Router listens on `127.0.0.1:4403`. + +## Deploy + +```bash +python deploy/router/deploy_router.py +``` + +Or on server: + +```bash +cd /var/www/nomadro-router +npm install --omit=dev +cp nomadro-router.service /etc/systemd/system/ +systemctl enable --now nomadro-router +cp /path/to/Caddyfile /etc/caddy/Caddyfile +systemctl reload caddy +``` diff --git a/deploy/router/nomadro-router.service b/deploy/router/nomadro-router.service new file mode 100644 index 0000000..021a066 --- /dev/null +++ b/deploy/router/nomadro-router.service @@ -0,0 +1,17 @@ +[Unit] +Description=Nomadro audience router (zh→Next, en→WordPress) +After=network.target docker.service nomadro.service +Wants=nomadro.service + +[Service] +Type=simple +WorkingDirectory=/var/www/nomadro-router +Environment=PORT=4403 +Environment=NEXT_UPSTREAM=http://127.0.0.1:4003 +Environment=WP_UPSTREAM=http://127.0.0.1:8088 +ExecStart=/usr/bin/node /var/www/nomadro-router/server.mjs +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/router/package.json b/deploy/router/package.json new file mode 100644 index 0000000..c34383d --- /dev/null +++ b/deploy/router/package.json @@ -0,0 +1,8 @@ +{ + "name": "nomadro-router", + "private": true, + "type": "module", + "dependencies": { + "http-proxy": "^1.18.1" + } +} diff --git a/deploy/router/server.mjs b/deploy/router/server.mjs new file mode 100644 index 0000000..ca98219 --- /dev/null +++ b/deploy/router/server.mjs @@ -0,0 +1,160 @@ +/** + * Audience router for nomadro.com + * - Chinese users → Next.js :4003 + * - English users / bots / default → WordPress :8088 + * + * Detection mirrors is-china-user primary signals (language + timezone), + * evaluated in a same-origin sniff page (no external CDN). + */ +import http from "node:http"; +import httpProxy from "http-proxy"; + +const PORT = Number(process.env.PORT || 4403); +const NEXT = process.env.NEXT_UPSTREAM || "http://127.0.0.1:4003"; +const WP = process.env.WP_UPSTREAM || "http://127.0.0.1:8088"; +const COOKIE = "nomadro_route"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; + +const proxy = httpProxy.createProxyServer({ + xfwd: true, + changeOrigin: false, +}); + +proxy.on("error", (err, _req, res) => { + console.error("proxy error", err.message); + if (res && !res.headersSent && typeof res.writeHead === "function") { + res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Bad gateway"); + } +}); + +const BOT_RE = + /Googlebot|AdsBot-Google|Mediapartners-Google|Google-InspectionTool|bingbot|BingPreview|DuckDuckBot|Baiduspider|YandexBot|Slurp|facebookexternalhit|Twitterbot|LinkedInBot|Applebot|SemrushBot|AhrefsBot|DotBot|PetalBot|Bytespider/i; + +const WP_PATH_RE = + /^\/(wp-admin|wp-login\.php|wp-cron\.php|wp-comments-post\.php|xmlrpc\.php|wp-json|wp-content|wp-includes)(\/|$)/i; + +function getCookie(req, name) { + const raw = req.headers.cookie || ""; + for (const p of raw.split(/;\s*/)) { + const i = p.indexOf("="); + if (i === -1) continue; + if (p.slice(0, i) === name) return decodeURIComponent(p.slice(i + 1)); + } + return null; +} + +function isBot(req) { + return BOT_RE.test(req.headers["user-agent"] || ""); +} + +function wantsHtml(req) { + if (req.method !== "GET" && req.method !== "HEAD") return false; + const accept = req.headers.accept || ""; + const url = req.url || "/"; + const path = url.split("?")[0]; + if (path.startsWith("/_next/") || path.startsWith("/wp-")) return false; + if (/\.(js|css|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|txt|xml)$/i.test(path)) { + return false; + } + if (!accept || accept === "*/*") return path === "/" || !path.includes("."); + return accept.includes("text/html"); +} + +function sniffHtml() { + // Inline detection aligned with is-china-user language + timezone signals. + return ` + + + + + + Nomadro + + + +

Loading…

+ + + +`; +} + +function resolveTarget(req) { + const url = req.url || "/"; + const path = url.split("?")[0]; + + if (WP_PATH_RE.test(path)) return WP; + if (isBot(req)) return WP; + if (url.includes("noscript=1")) return WP; + + const route = getCookie(req, COOKIE); + if (route === "zh") return NEXT; + if (route === "en") return WP; + + if (wantsHtml(req)) return "SNIFF"; + + const lang = String(req.headers["accept-language"] || "").toLowerCase(); + if (lang.includes("zh")) return NEXT; + return WP; +} + +const server = http.createServer((req, res) => { + const target = resolveTarget(req); + if (target === "SNIFF") { + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store, no-cache, must-revalidate", + }); + res.end(sniffHtml()); + return; + } + if (!req.headers["x-forwarded-proto"]) { + req.headers["x-forwarded-proto"] = "https"; + } + proxy.web(req, res, { target }); +}); + +server.on("upgrade", (req, socket, head) => { + let target = resolveTarget(req); + if (target === "SNIFF") target = WP; + proxy.ws(req, socket, head, { target }); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`nomadro-router 127.0.0.1:${PORT} zh→${NEXT} en→${WP}`); +}); diff --git a/deploy/umami/docker-compose.yml b/deploy/umami/docker-compose.yml new file mode 100644 index 0000000..1700765 --- /dev/null +++ b/deploy/umami/docker-compose.yml @@ -0,0 +1,39 @@ +# Umami analytics — bind localhost only; Caddy terminates TLS at umami.nomadro.com +services: + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + restart: unless-stopped + ports: + - "127.0.0.1:3010:3000" + environment: + DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami + APP_SECRET: ${APP_SECRET} + DISABLE_TELEMETRY: "1" + depends_on: + db: + condition: service_healthy + init: true + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 40s + + db: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: umami + POSTGRES_USER: umami + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - umami_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U umami -d umami"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + umami_db_data: diff --git a/deploy/wordpress/Caddyfile.adsense b/deploy/wordpress/Caddyfile.adsense new file mode 100644 index 0000000..3ec0fc0 --- /dev/null +++ b/deploy/wordpress/Caddyfile.adsense @@ -0,0 +1,26 @@ +www.nomadro.com { + redir https://nomadro.com{uri} permanent +} + +# Audience router: zh → Next.js, en/bots → WordPress +nomadro.com { + encode gzip + reverse_proxy 127.0.0.1:4403 +} + +# Direct Next.js (optional / debugging) +portal.nomadro.com { + encode gzip + reverse_proxy 127.0.0.1:4003 +} + +x.nomadro.com { + encode gzip + reverse_proxy 127.0.0.1:8000 + request_body { + max_size 50MB + } +} + +# HackRobot isolated sites +import /etc/caddy/sites/*.caddy diff --git a/deploy/wordpress/README.md b/deploy/wordpress/README.md new file mode 100644 index 0000000..2b791b6 --- /dev/null +++ b/deploy/wordpress/README.md @@ -0,0 +1,53 @@ +# WordPress blog for AdSense (English default) + +During AdSense review, **nomadro.com** serves an English WordPress blog. +The existing Next.js site remains on **portal.nomadro.com**. + +## Layout on server + +``` +/var/www/nomadro-wp/ # docker compose project + docker-compose.yml + .env + bootstrap-wp.sh + content/ +``` + +WordPress listens on `127.0.0.1:8088`. Caddy terminates HTTPS. + +## First-time deploy + +```bash +# 1) Install Docker +bash /var/www/nomadro.com/deploy/wordpress/install-docker.sh + +# 2) Copy project + secrets +mkdir -p /var/www/nomadro-wp +cp -a /var/www/nomadro.com/deploy/wordpress/. /var/www/nomadro-wp/ +cd /var/www/nomadro-wp +cp .env.example .env +# edit .env passwords + +# 3) Start stack +docker compose up -d + +# 4) Point domain to WordPress (keeps Next on portal.) +cp /var/www/nomadro-wp/Caddyfile.adsense /etc/caddy/Caddyfile +caddy validate --config /etc/caddy/Caddyfile +systemctl reload caddy + +# 5) Seed English content +bash bootstrap-wp.sh +``` + +## DNS + +| Host | Type | Value | +| --- | --- | --- | +| nomadro.com | A | 107.173.30.245 | +| www.nomadro.com | A | 107.173.30.245 | +| portal.nomadro.com | A | 107.173.30.245 | + +## After AdSense approval + +You can switch the primary domain back to Next.js, or keep WordPress as the public blog and link to the portal. diff --git a/deploy/wordpress/ads.txt b/deploy/wordpress/ads.txt new file mode 100644 index 0000000..6d7968e --- /dev/null +++ b/deploy/wordpress/ads.txt @@ -0,0 +1 @@ +google.com, pub-8461145959530971, DIRECT, f08c47fec0942fa0 diff --git a/deploy/wordpress/bootstrap-wp.sh b/deploy/wordpress/bootstrap-wp.sh new file mode 100644 index 0000000..ed47665 --- /dev/null +++ b/deploy/wordpress/bootstrap-wp.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Bootstrap English WordPress for AdSense review (idempotent-ish). +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +if [[ ! -f .env ]]; then + echo "Missing .env — copy from .env.example and set passwords" + exit 1 +fi +# shellcheck disable=SC1091 +source .env + +WP() { + docker compose --profile tools run --rm wpcli "$@" +} + +echo "==> Waiting for WordPress HTTP..." +for i in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:8088" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +if ! WP core is-installed >/dev/null 2>&1; then + echo "==> Installing WordPress (English)" + WP core install \ + --url="https://nomadro.com" \ + --title="Nomadro" \ + --admin_user="${WP_ADMIN_USER}" \ + --admin_password="${WP_ADMIN_PASSWORD}" \ + --admin_email="${WP_ADMIN_EMAIL}" \ + --skip-email +fi + +echo "==> Core settings" +WP language core install en_US --activate || true +WP option update blogdescription "Digital nomad guides for remote work, travel, and location-independent income" +WP option update WPLANG "" +WP option update blog_public 1 +WP option update timezone_string "UTC" +WP option update date_format "F j, Y" +WP option update time_format "g:i a" +WP option update start_of_week 1 +WP option update show_on_front posts +WP rewrite structure '/%postname%/' --hard +WP rewrite flush --hard + +echo "==> Theme + essentials" +WP theme install twentytwentyfive --activate || WP theme activate twentytwentyfive || true +WP plugin install wordpress-seo --activate || true +WP option patch update wpseo_titles noindex-author-wpseo true || true + +# Remove Hello World / Sample Page if present +HELLO_ID=$(WP post list --post_type=post --name=hello-world --field=ID 2>/dev/null || true) +if [[ -n "${HELLO_ID}" ]]; then + WP post delete "${HELLO_ID}" --force || true +fi +SAMPLE_ID=$(WP post list --post_type=page --name=sample-page --field=ID 2>/dev/null || true) +if [[ -n "${SAMPLE_ID}" ]]; then + WP post delete "${SAMPLE_ID}" --force || true +fi + +create_page() { + local slug="$1" + local title="$2" + local file="$3" + local existing + existing=$(WP post list --post_type=page --name="$slug" --field=ID 2>/dev/null || true) + if [[ -n "$existing" ]]; then + echo "Page exists: $slug ($existing)" + return + fi + WP post create "$file" --post_type=page --post_title="$title" --post_name="$slug" --post_status=publish +} + +create_post() { + local slug="$1" + local title="$2" + local file="$3" + local existing + existing=$(WP post list --post_type=post --name="$slug" --field=ID 2>/dev/null || true) + if [[ -n "$existing" ]]; then + echo "Post exists: $slug ($existing)" + return + fi + WP post create "$file" --post_type=post --post_title="$title" --post_name="$slug" --post_status=publish +} + +echo "==> Policy / trust pages" +create_page about "About Nomadro" /content/about.html +create_page contact "Contact" /content/contact.html +create_page privacy-policy "Privacy Policy" /content/privacy-policy.html +create_page terms "Terms of Service" /content/terms.html + +echo "==> English articles" +create_post what-is-a-digital-nomad "What Is a Digital Nomad? A Practical Definition" /content/posts/01-what-is-a-digital-nomad.html +create_post how-to-start-remote-work "How to Start Remote Work Without Quitting Overnight" /content/posts/02-start-remote-work.html +create_post geo-arbitrage-basics "Geo-Arbitrage Basics: Lower Costs Without Lowering Ambition" /content/posts/03-geo-arbitrage.html +create_post packing-list-long-stay "A Practical Packing List for Long-Stay Travel" /content/posts/04-packing-list.html +create_post coworking-vs-home "Coworking vs Home Office Abroad: How to Choose" /content/posts/05-coworking-vs-home.html +create_post internet-checklist "Internet Reliability Checklist for Remote Workers" /content/posts/06-internet-checklist.html +create_post time-zone-collaboration "Working Across Time Zones Without Burning Out" /content/posts/07-time-zones.html +create_post building-online-income "Building Online Income Streams as a Nomad" /content/posts/08-online-income.html +create_post city-research-framework "A Simple Framework for Researching Your Next City" /content/posts/09-city-research.html +create_post live-streaming-toolkit "Live Streaming Toolkit Basics for Creators on the Move" /content/posts/10-live-streaming.html +create_post banking-and-payments "Banking and Payments Tips for Location-Independent Work" /content/posts/11-banking-payments.html +create_post routines-that-travel "Routines That Travel: Habits That Survive New Cities" /content/posts/12-routines.html + +# Footer menu with trust pages +MENU_ID=$(WP menu list --fields=term_id,name --format=csv 2>/dev/null | awk -F, '$2=="Footer"{print $1; exit}' || true) +if [[ -z "${MENU_ID}" ]]; then + MENU_ID=$(WP menu create "Footer" --porcelain) +fi +# Avoid duplicate items by recreating links carefully +for slug in about contact privacy-policy terms; do + PAGE_ID=$(WP post list --post_type=page --name="$slug" --field=ID) + if [[ -n "$PAGE_ID" ]]; then + WP menu item add-post "$MENU_ID" "$PAGE_ID" || true + fi +done +WP menu location assign "$MENU_ID" footer || true +WP menu location assign "$MENU_ID" primary || true + +echo "==> Done. Admin: https://nomadro.com/wp-admin/" diff --git a/deploy/wordpress/content/about.html b/deploy/wordpress/content/about.html new file mode 100644 index 0000000..209e654 --- /dev/null +++ b/deploy/wordpress/content/about.html @@ -0,0 +1,11 @@ +

Nomadro is an English-language digital nomad publication focused on practical remote work, travel systems, and location-independent income.

+

We write for people who need checklists, decision frameworks, and operating habits—not hype. Topics include destination research, housing, connectivity, productivity, creator tooling, and risk management while working from different cities.

+

Editorial standards

+
    +
  • Guides prioritize usefulness and clarity over trend-chasing.
  • +
  • We separate educational overviews from personalized professional advice.
  • +
  • Articles are reviewed for structure, originality, and reader actionability.
  • +
+

Who publishes Nomadro

+

Content is produced by Nomadro Editorial. For corrections, partnerships, or questions, email hello@nomadro.com.

+

Nomadro is operated independently. We may display advertisements (including Google AdSense) to support the site.

diff --git a/deploy/wordpress/content/contact.html b/deploy/wordpress/content/contact.html new file mode 100644 index 0000000..48b477b --- /dev/null +++ b/deploy/wordpress/content/contact.html @@ -0,0 +1,12 @@ +

We read every message. For the fastest reply, include your name, the page URL (if relevant), and a short summary.

+

Email

+

hello@nomadro.com

+

What to contact us about

+
    +
  • Content corrections or update requests
  • +
  • Partnership or sponsorship inquiries
  • +
  • Privacy or data requests
  • +
  • Technical issues on nomadro.com
  • +
+

Please do not send passwords, payment credentials, or other sensitive account details by email.

+

We aim to respond within a few business days.

diff --git a/deploy/wordpress/content/disclaimer.html b/deploy/wordpress/content/disclaimer.html new file mode 100644 index 0000000..8b86ffe --- /dev/null +++ b/deploy/wordpress/content/disclaimer.html @@ -0,0 +1,10 @@ +

Last updated: August 16, 2026

+

Nomadro publishes educational guides about digital nomad lifestyles, remote work, and travel systems.

+

General information only

+

Nothing on this website is personalized legal, tax, immigration, financial, medical, or professional advice. Laws, visas, banking rules, and insurance products change and vary by nationality and destination.

+

Your responsibility

+

Before making decisions that affect your income, travel, health, or compliance obligations, verify information with official sources and qualified professionals.

+

Affiliate and advertising disclosure

+

Nomadro may earn revenue from advertising partners, including Google AdSense, and may mention tools or services that could have commercial relationships in the future. Editorial recommendations aim to remain useful regardless of monetization.

+

Contact

+

Questions: hello@nomadro.com.

diff --git a/deploy/wordpress/content/posts/01-what-is-a-digital-nomad.html b/deploy/wordpress/content/posts/01-what-is-a-digital-nomad.html new file mode 100644 index 0000000..8c54f98 --- /dev/null +++ b/deploy/wordpress/content/posts/01-what-is-a-digital-nomad.html @@ -0,0 +1,15 @@ +

A digital nomad is someone who earns income remotely while choosing where to live for stretches of time—weeks, months, or seasons—rather than being tied to one office city.

+

The label is popular, but the practical definition is narrower than social media suggests. Travel alone does not make someone a nomad. The core ingredients are portable work, intentional location choices, and systems that keep delivery reliable when Wi‑Fi, visas, and routines change.

+

What usually qualifies

+
    +
  • Income that does not require daily physical presence at one employer site
  • +
  • A repeatable way to find housing, internet, and focus time in new places
  • +
  • Clear boundaries so travel does not quietly destroy work quality
  • +
+

What does not automatically qualify

+
    +
  • Vacation with a laptop open for email
  • +
  • One long trip funded only by savings with no remote earning plan
  • +
  • Constant movement without a sustainable workload
  • +
+

If you are exploring this path, start by stress-testing your work: can you ship for two weeks with only a backpack setup? If not, improve the work system before optimizing the itinerary.

diff --git a/deploy/wordpress/content/posts/02-start-remote-work.html b/deploy/wordpress/content/posts/02-start-remote-work.html new file mode 100644 index 0000000..f4194f1 --- /dev/null +++ b/deploy/wordpress/content/posts/02-start-remote-work.html @@ -0,0 +1,11 @@ +

Most people do not need a dramatic resignation letter. A safer path is to build remote capability while your current role still pays the bills.

+

A staged approach

+
    +
  1. Document portable skills. List tasks you already do that can be delivered asynchronously: writing, analysis, support, design, code, sales follow-up.
  2. +
  3. Create proof. Ship small public artifacts—case notes, templates, a portfolio page, or a short process write-up.
  4. +
  5. Negotiate flexibility first. Ask for hybrid or remote days before you ask for full independence.
  6. +
  7. Build a cash buffer. Aim for several months of essential expenses before you detach housing from a single city.
  8. +
+

Common failure mode

+

People optimize flights and coworking memberships before they stabilize client delivery or employer trust. Reverse that order. Reliability is the product. Travel is the lifestyle layer on top.

+

When you can deliver consistently from two different locations for a month, you have a real remote foundation—not just a travel fantasy.

diff --git a/deploy/wordpress/content/posts/03-geo-arbitrage.html b/deploy/wordpress/content/posts/03-geo-arbitrage.html new file mode 100644 index 0000000..1c36d25 --- /dev/null +++ b/deploy/wordpress/content/posts/03-geo-arbitrage.html @@ -0,0 +1,12 @@ +

Geo-arbitrage means earning in a stronger currency or higher-rate market while spending in a place with lower living costs—without pretending every low-cost city is automatically a good base.

+

What to compare honestly

+
    +
  • Reliable housing with desks and quiet hours
  • +
  • Internet quality during your peak work window
  • +
  • Visa duration and renewability
  • +
  • Healthcare access and everyday safety
  • +
  • Community fit (or deliberate solitude)
  • +
+

A simple math check

+

Track your true monthly burn: rent, food, transport, coworking, insurance, and “friction costs” like bad Wi‑Fi days and visa runs. Then compare that number to after-tax remote income. If the margin only works when everything goes perfectly, the plan is fragile.

+

Geo-arbitrage works best as a calm operating choice, not a race to the cheapest city on a listicle.

diff --git a/deploy/wordpress/content/posts/04-packing-list.html b/deploy/wordpress/content/posts/04-packing-list.html new file mode 100644 index 0000000..0eb5a4a --- /dev/null +++ b/deploy/wordpress/content/posts/04-packing-list.html @@ -0,0 +1,12 @@ +

Overpacking is the most common first-trip tax. Underpacking creates expensive replacements. Aim for a kit you can rebuild in any mid-size city.

+

Core kit

+
    +
  • Laptop, charger, compact adapter, and a small spare cable set
  • +
  • Noise-reducing earbuds and a lightweight laptop stand
  • +
  • One versatile “meeting” outfit and two durable daily outfits
  • +
  • Basic first-aid and any prescription refills with buffer
  • +
  • A document pack: passport copies, insurance card, emergency contacts
  • +
+

What to leave behind

+

Specialty gadgets you have not used in 30 days, duplicate toiletries, and “just in case” clothes for imaginary events. If an item is cheap and available at destination, buy it there.

+

Revisit your bag after every trip. The best packing list is the one your past self already stress-tested.

diff --git a/deploy/wordpress/content/posts/05-coworking-vs-home.html b/deploy/wordpress/content/posts/05-coworking-vs-home.html new file mode 100644 index 0000000..f33902e --- /dev/null +++ b/deploy/wordpress/content/posts/05-coworking-vs-home.html @@ -0,0 +1,14 @@ +

Neither coworking nor a home desk is universally better. The right choice depends on your work style, city noise, and how long you plan to stay.

+

Choose coworking when

+
    +
  • You need social accountability or networking
  • +
  • Your housing internet is unreliable
  • +
  • You take frequent video calls and need backup power or quiet booths
  • +
+

Choose a home setup when

+
    +
  • Your deep work requires long uninterrupted blocks
  • +
  • You already have stable fiber and a proper desk
  • +
  • You are optimizing for cost during a multi-month stay
  • +
+

Many nomads use a hybrid: deep work at home in the morning, coworking for calls and community later. Test for one week before buying an annual membership.

diff --git a/deploy/wordpress/content/posts/06-internet-checklist.html b/deploy/wordpress/content/posts/06-internet-checklist.html new file mode 100644 index 0000000..578a483 --- /dev/null +++ b/deploy/wordpress/content/posts/06-internet-checklist.html @@ -0,0 +1,10 @@ +

For remote workers, internet is not a convenience—it is infrastructure. Evaluate it with the same seriousness you give housing.

+

Checklist before you commit to a stay

+
    +
  1. Run speed tests at your actual work hours, not only at noon.
  2. +
  3. Ask the host for the provider name and whether the line is shared.
  4. +
  5. Confirm a backup option: nearby cafe, coworking day pass, or mobile hotspot plan.
  6. +
  7. Test a full video call, screen share, and file upload.
  8. +
  9. Know the building’s outage pattern if neighbors can tell you.
  10. +
+

If your income depends on live sessions, never arrive without at least one tested backup path. A cheap apartment with brittle Wi‑Fi is more expensive than a slightly higher rent with stable fiber.

diff --git a/deploy/wordpress/content/posts/07-time-zones.html b/deploy/wordpress/content/posts/07-time-zones.html new file mode 100644 index 0000000..504153a --- /dev/null +++ b/deploy/wordpress/content/posts/07-time-zones.html @@ -0,0 +1,10 @@ +

Time zones do not have to ruin your health. They become a problem when every meeting is treated as equally urgent.

+

Practical rules

+
    +
  • Protect one deep-work block that never moves for low-value meetings.
  • +
  • Batch overlapping collaboration into a defined “office overlap” window.
  • +
  • Use async updates (recorded loom-style notes, written status) before requesting live calls.
  • +
  • Cap late-night meetings to a weekly budget, then enforce it.
  • +
+

Tell collaborators your overlap hours clearly. Ambiguity creates after-hours expectations. Clarity creates trust.

+

If your role cannot tolerate any async work, reconsider whether continuous travel is compatible with that job design.

diff --git a/deploy/wordpress/content/posts/08-online-income.html b/deploy/wordpress/content/posts/08-online-income.html new file mode 100644 index 0000000..3b0990f --- /dev/null +++ b/deploy/wordpress/content/posts/08-online-income.html @@ -0,0 +1,10 @@ +

Location independence is more resilient when income is not a single fragile stream. Diversification does not mean doing everything at once—it means sequencing.

+

A sensible sequence

+
    +
  1. Stabilize a primary remote income (salary, retainer, or consistent clients).
  2. +
  3. Add a lightweight secondary that compounds: a niche newsletter, templates, affiliate education content, or a small productized service.
  4. +
  5. Automate and document before expanding volume.
  6. +
+

Quality filter

+

If a side project cannot survive a travel week with bad internet, it is not yet nomad-ready. Simplify delivery until a delayed flight does not create a client emergency.

+

Income systems should travel better than your suitcase.

diff --git a/deploy/wordpress/content/posts/09-city-research.html b/deploy/wordpress/content/posts/09-city-research.html new file mode 100644 index 0000000..0c81cf2 --- /dev/null +++ b/deploy/wordpress/content/posts/09-city-research.html @@ -0,0 +1,10 @@ +

Choosing a city from Instagram is how people burn a month of rent. Use a short research framework instead.

+

The five-question filter

+
    +
  1. Can I legally stay long enough for this trip’s goal?
  2. +
  3. Is housing + internet available in my budget at my work hours?
  4. +
  5. Do I have a health and safety plan that fits my risk tolerance?
  6. +
  7. Will this place support my energy—quiet, social, nature, or urban density?
  8. +
  9. What is my exit plan if the first neighborhood is wrong?
  10. +
+

Score each question 1–5. If any critical item scores a 1, fix it before booking long-term housing. A great cafe scene cannot compensate for an unworkable visa or nightly blackouts.

diff --git a/deploy/wordpress/content/posts/10-live-streaming.html b/deploy/wordpress/content/posts/10-live-streaming.html new file mode 100644 index 0000000..57e6ee9 --- /dev/null +++ b/deploy/wordpress/content/posts/10-live-streaming.html @@ -0,0 +1,11 @@ +

Creators who travel need a streaming kit that is light, redundant, and fast to rebuild after a flight.

+

Baseline toolkit

+
    +
  • Phone or camera with a stable mounting option
  • +
  • Clip-on mic or compact wireless mic with spare batteries
  • +
  • Portable light that works in mixed hotel lighting
  • +
  • Hotspot backup independent of venue Wi‑Fi
  • +
  • A repeatable scene template so setup takes minutes, not hours
  • +
+

Practice a full stream from a hotel room before you depend on it for income. The weakest link is usually audio or uplink reliability—not the camera body.

+

Nomadro’s broader product direction includes live tooling for creators; this guide focuses on the portable fundamentals that apply anywhere.

diff --git a/deploy/wordpress/content/posts/11-banking-payments.html b/deploy/wordpress/content/posts/11-banking-payments.html new file mode 100644 index 0000000..9cf8714 --- /dev/null +++ b/deploy/wordpress/content/posts/11-banking-payments.html @@ -0,0 +1,11 @@ +

Moving between countries stresses payment systems. Build boring reliability before you optimize rewards points.

+

Foundational setup

+
    +
  • At least two working cards from different networks
  • +
  • A primary bank that does not panic at foreign charges
  • +
  • A documented way to receive client payments (and what fees apply)
  • +
  • Offline copies of key account recovery codes stored securely
  • +
+

Operating habits

+

Notify banks before long travel when required, keep a small cash float for outages, and separate “operating” and “savings” accounts so a frozen travel card does not freeze your entire runway.

+

Always verify tax and reporting obligations for your citizenship and residency situation with a qualified professional—payment apps do not replace compliance.

diff --git a/deploy/wordpress/content/posts/12-routines.html b/deploy/wordpress/content/posts/12-routines.html new file mode 100644 index 0000000..bef2a9a --- /dev/null +++ b/deploy/wordpress/content/posts/12-routines.html @@ -0,0 +1,10 @@ +

Motivation is unreliable across time zones. Routines are what keep delivery quality stable when the scenery changes.

+

Portable routine template

+
    +
  • Morning anchor: same wake window, water, and a 10-minute planning note
  • +
  • Work block: one protected deep-work session before messages
  • +
  • Body reset: a walk or short mobility session between blocks
  • +
  • Shutdown ritual: tomorrow’s top three tasks written before you close the laptop
  • +
+

Keep the structure identical even when the city changes. Novelty belongs in evenings and weekends; work systems should feel familiar.

+

If a routine requires specialized equipment, redesign it. The best nomad habits fit in a backpack and a calendar.

diff --git a/deploy/wordpress/content/posts/batch2/13-first-destination.html b/deploy/wordpress/content/posts/batch2/13-first-destination.html new file mode 100644 index 0000000..ed72349 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/13-first-destination.html @@ -0,0 +1,21 @@ +

Choosing a first digital nomad destination is less about finding the “best city on earth” and more about reducing avoidable failure modes. Your first base should help you prove that remote work and travel can coexist—not maximize Instagram density.

+ +

Start with constraints, not vibes

+

Write down non-negotiables before you open maps. Typical constraints include visa length, budget ceiling, required overlap hours with clients, and minimum internet reliability. If a city fails a hard constraint, remove it immediately. Inspiration lists are useful only after filters.

+ +

A practical first-city scorecard

+
    +
  • Legal stay: Can you remain long enough to settle into a work rhythm (ideally 30–90 days)?
  • +
  • Housing quality: Desk, chair, quiet hours, and a refundable booking path.
  • +
  • Connectivity: Fiber or strong cable plus a tested mobile hotspot backup.
  • +
  • Healthcare access: Clinics you can reach without drama.
  • +
  • Social fit: Enough community if you need it, or enough solitude if you do not.
  • +
+ +

Prefer “boring reliable” over “perfect cheap”

+

Many first-timers over-optimize for cost and under-optimize for sleep and uplink. A slightly more expensive apartment with stable internet often produces better income outcomes than a bargain room that drops calls twice a day.

+ +

Run a two-week proof loop

+

In the first fourteen days, track three metrics: deep-work hours completed, failed video calls, and nights of poor sleep. If those metrics are acceptable, extend. If not, change neighborhood or city before you dig into long-term leases.

+ +

Your first destination is a systems test. Treat it like a pilot program, not a permanent identity.

diff --git a/deploy/wordpress/content/posts/batch2/14-housing-abroad.html b/deploy/wordpress/content/posts/batch2/14-housing-abroad.html new file mode 100644 index 0000000..aa80c16 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/14-housing-abroad.html @@ -0,0 +1,31 @@ +

Long-stay housing is where many nomad plans quietly break. The goal is not the cheapest listing—it is a place where you can work for weeks without constant renegotiation.

+ +

What “work-ready” housing actually means

+
    +
  • A real desk or table at sitting height (not a bed tray)
  • +
  • A chair that does not destroy your back in three days
  • +
  • Quiet enough for calls during your overlap window
  • +
  • Confirmed internet type and a backup plan if it fails
  • +
  • Clear entry rules, deposits, and cancellation terms
  • +
+ +

Red flags that predict expensive pain

+
    +
  • Hosts who refuse a short video walkthrough of the desk and Wi‑Fi modem
  • +
  • Listings that only show nightlife photos and no workspace
  • +
  • Pressure to pay outside the platform with “friends and family” transfers
  • +
  • Unusually low prices in high-demand months with no reviews
  • +
+ +

A safer booking sequence

+
    +
  1. Shortlist three places that meet work criteria.
  2. +
  3. Message each host with the same checklist questions.
  4. +
  5. Book the strongest answer, not the prettiest photos.
  6. +
  7. Keep a cancelable backup for the first week when possible.
  8. +
+ +

After you arrive

+

On day one, test upload speed, a full video call, and evening noise. If the place fails, escalate immediately while refund windows still matter. Waiting “to see if it improves” is how temporary discomfort becomes a wasted month.

+ +

Housing is infrastructure. Choose it with the same seriousness you give to your laptop.

diff --git a/deploy/wordpress/content/posts/batch2/15-visa-planning.html b/deploy/wordpress/content/posts/batch2/15-visa-planning.html new file mode 100644 index 0000000..10757ec --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/15-visa-planning.html @@ -0,0 +1,20 @@ +

Visa rules change and personal situations differ, so this is not legal advice. It is an operating framework nomads use to avoid building a beautiful itinerary on an illegal or fragile stay.

+ +

Separate “entry” from “sustainable stay”

+

Tourist entry can be easy while long stays are hard. Before you plan quarters in a city, answer: What is the maximum lawful stay for my passport? Can it be extended in-country? What happens if plans change mid-trip?

+ +

Build a stay calendar

+

Create a simple sheet with columns for country, entry date, permitted days, buffer days, and exit options. Add a seven-day buffer before hard limits. Buffers absorb delays, illness, and housing mistakes.

+ +

Watch for hidden constraints

+
    +
  • Proof of onward travel requests
  • +
  • Minimum bank balance expectations for some visa types
  • +
  • Registration requirements after arrival
  • +
  • Rules about remote work while on a visitor status
  • +
+ +

Decision rule

+

If you cannot explain your stay basis in one clear sentence, you are not ready to book non-refundable housing. Clarity first, tickets second.

+ +

Always verify requirements with official sources or a qualified advisor for your nationality and destination. Treat forum anecdotes as leads, not authority.

diff --git a/deploy/wordpress/content/posts/batch2/16-avoid-scams.html b/deploy/wordpress/content/posts/batch2/16-avoid-scams.html new file mode 100644 index 0000000..642ce58 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/16-avoid-scams.html @@ -0,0 +1,23 @@ +

Scams thrive where travelers are rushed, tired, and paying from unfamiliar payment rails. A short checklist prevents most expensive mistakes.

+ +

Common patterns

+
    +
  • Too-good housing with urgent “pay now or lose it” pressure
  • +
  • Fake coworking “annual deals” sold in chat apps
  • +
  • SIM or eSIM sellers that vanish after payment
  • +
  • Support impersonators asking for remote access to your laptop
  • +
+ +

Verification habits that work

+
    +
  1. Prefer platforms with dispute resolution for first stays.
  2. +
  3. Confirm identities through official domains, not shortened links.
  4. +
  5. Never share one-time codes, seed phrases, or remote desktop access.
  6. +
  7. Keep screenshots of listings, chats, and receipts in one folder.
  8. +
+ +

Money movement hygiene

+

Use cards with strong fraud controls for experiments. Keep a low daily limit on travel cards. Separate a small “operating” balance from long-term savings so a freeze does not halt your trip.

+ +

If something feels off

+

Pause. Scammers sell urgency. Legitimate hosts and services can usually wait one business day while you verify. That pause is often the entire defense.

diff --git a/deploy/wordpress/content/posts/batch2/17-productivity-hotels-cafes.html b/deploy/wordpress/content/posts/batch2/17-productivity-hotels-cafes.html new file mode 100644 index 0000000..d4ebf86 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/17-productivity-hotels-cafes.html @@ -0,0 +1,18 @@ +

Hotel rooms and cafes can be productive if you design for constraints instead of pretending you have an office.

+ +

The portable deep-work kit

+
    +
  • Laptop stand or stable stack that raises the screen
  • +
  • Compact keyboard if you type for long blocks
  • +
  • Noise control: earbuds plus a fallback quiet hour plan
  • +
  • One lighting option that does not rely on harsh ceiling bulbs
  • +
+ +

Cafe rules that protect focus

+

Do not invent a new workflow every morning. Use the same sequence: arrive, order, set a 50-minute timer, messages only after the timer. Rotate cafes when staff or noise patterns change, but keep the sequence identical.

+ +

Hotel room tactics

+

Ask for a room away from elevators and ice machines. Place the desk near the best light. If the chair is unusable, buy a cheap local fix once—then carry only what repeatedly earns its weight.

+ +

Define “good enough” days

+

Travel weeks will not match home office output. Set a minimum viable workday (for example, one deep block plus communications). Hitting the minimum consistently beats occasional heroic days followed by collapse.

diff --git a/deploy/wordpress/content/posts/batch2/18-health-insurance-basics.html b/deploy/wordpress/content/posts/batch2/18-health-insurance-basics.html new file mode 100644 index 0000000..f16b495 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/18-health-insurance-basics.html @@ -0,0 +1,18 @@ +

Health logistics are not glamorous, but they determine whether a nomad lifestyle is durable. This is general information, not medical or insurance advice.

+ +

Coverage questions to answer before you fly

+
    +
  • Does your plan cover outpatient care in your destination countries?
  • +
  • What is the emergency evacuation story, if any?
  • +
  • Are pre-existing conditions handled clearly?
  • +
  • How do claims work when you pay cash at a clinic?
  • +
+ +

Build a simple health packet

+

Store digital copies of insurance cards, policy numbers, allergy notes, and preferred clinic contacts. Keep an offline copy. Share emergency details with one trusted person.

+ +

Day-to-day resilience

+

Sleep, hydration, and basic movement prevent more lost workdays than most gadgets. When you change cities, protect the first 48 hours for recovery instead of stacking meetings.

+ +

Know your local care path

+

On arrival, identify one clinic and one pharmacy near your housing. Searching during a fever is a bad user experience. Preparation turns emergencies into procedures.

diff --git a/deploy/wordpress/content/posts/batch2/19-jet-lag-sleep.html b/deploy/wordpress/content/posts/batch2/19-jet-lag-sleep.html new file mode 100644 index 0000000..dd7d25b --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/19-jet-lag-sleep.html @@ -0,0 +1,18 @@ +

Jet lag is a work problem disguised as a travel story. If your income depends on judgment and communication, sleep debt becomes a business risk.

+ +

Shift before you fly when possible

+

Move your sleep window one to two hours per day toward the destination zone in the final days before departure. Perfect alignment is unnecessary; reducing the gap matters.

+ +

First 72 hours protocol

+
    +
  • Get morning light in the new local morning.
  • +
  • Avoid stacking critical negotiations on day one.
  • +
  • Use short naps (20–30 minutes) instead of long daytime sleep.
  • +
  • Keep caffeine earlier than your local afternoon when you can.
  • +
+ +

Protect one recovery block

+

Block a recovery window on your calendar the way you would block a client workshop. If everything is marked urgent, sleep loses—and then quality loses.

+ +

Team communication

+

Tell collaborators you are in transition for a defined period. Ambiguous availability creates after-hours expectations. Clear windows create trust.

diff --git a/deploy/wordpress/content/posts/batch2/20-tax-residency-awareness.html b/deploy/wordpress/content/posts/batch2/20-tax-residency-awareness.html new file mode 100644 index 0000000..2a13ce1 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/20-tax-residency-awareness.html @@ -0,0 +1,15 @@ +

This article is educational, not tax advice. Cross-border tax residency rules are specific to citizenship, treaties, and personal facts. Use this as a question checklist before you speak with a qualified professional.

+ +

Questions worth answering early

+
    +
  • Where are you tax resident today, and what could change that status?
  • +
  • Do you have reporting obligations for foreign accounts or income?
  • +
  • How are freelancing, salary, and product income treated differently?
  • +
  • What records should you keep while moving (invoices, travel dates, contracts)?
  • +
+ +

Operating hygiene

+

Track entry and exit dates. Keep invoices and payment receipts organized by month. Do not assume a travel lifestyle automatically reduces tax obligations—sometimes it complicates them.

+ +

Avoid forum certainty

+

Anonymous posts rarely share full context. Two people in the same cafe can have opposite obligations. Professional advice scaled to your situation is cheaper than fixing multi-year mistakes.

diff --git a/deploy/wordpress/content/posts/batch2/21-solo-safety.html b/deploy/wordpress/content/posts/batch2/21-solo-safety.html new file mode 100644 index 0000000..32fc231 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/21-solo-safety.html @@ -0,0 +1,18 @@ +

Safety planning is not paranoia. It is how you keep working when environments change. Adjust intensity to your risk tolerance and destination.

+ +

Baseline habits

+
    +
  • Share live location with a trusted contact during transit days.
  • +
  • Use ATM and payment habits that limit exposure of balances.
  • +
  • Avoid broadcasting exact housing details publicly in real time.
  • +
  • Keep digital copies of ID documents offline and encrypted.
  • +
+ +

Housing and neighborhood checks

+

Arrive in daylight when you can. Confirm building access and emergency exits. Ask locals or coworkers about areas that feel uncomfortable at night for your profile.

+ +

Digital safety

+

Public Wi‑Fi is convenient and noisy. Prefer personal hotspots for banking and client portals. Keep OS updates current and use unique passwords with a manager.

+ +

Incident readiness

+

Save local emergency numbers, embassy contacts if relevant, and insurance claim steps. In a stressful moment, written steps beat improvisation.

diff --git a/deploy/wordpress/content/posts/batch2/22-cost-of-living-tracking.html b/deploy/wordpress/content/posts/batch2/22-cost-of-living-tracking.html new file mode 100644 index 0000000..e05d708 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/22-cost-of-living-tracking.html @@ -0,0 +1,19 @@ +

Geo-arbitrage only works when you measure real burn, not fantasy budgets. A lightweight tracking method beats complicated finance apps you abandon in week two.

+ +

Track five buckets

+
    +
  1. Housing
  2. +
  3. Food
  4. +
  5. Transport
  6. +
  7. Work infrastructure (coworking, SIM, cafes)
  8. +
  9. Friction (visas, replacements, bad-Wi‑Fi workarounds)
  10. +
+ +

Weekly review ritual

+

Every Sunday, spend fifteen minutes categorizing expenses and noting surprises. Friction costs reveal whether a city is truly cheap or only advertised as cheap.

+ +

Compare cities fairly

+

Normalize to a 30-day month and include the cost of reliable work conditions. A city that requires daily coworking to stay productive is not as inexpensive as the rent screenshot suggests.

+ +

Decision use

+

If your tracking shows stress rising while buffers shrink, change the plan early. Data turns vague anxiety into a clear move.

diff --git a/deploy/wordpress/content/posts/batch2/23-coworking-memberships.html b/deploy/wordpress/content/posts/batch2/23-coworking-memberships.html new file mode 100644 index 0000000..626bbfd --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/23-coworking-memberships.html @@ -0,0 +1,19 @@ +

Coworking can be leverage or an expensive cafe with a keycard. Evaluate memberships with the same clarity you use for software subscriptions.

+ +

Test before you commit

+

Buy day passes across different days and hours. Check call booth availability during your peak meeting window, not only at quiet midday times.

+ +

Score the membership

+
    +
  • Upload stability during real calls
  • +
  • Noise and desk ergonomics
  • +
  • Community usefulness versus distraction
  • +
  • Location relative to housing
  • +
  • Cancellation flexibility
  • +
+ +

Hybrid often wins

+

Many nomads keep deep work at home and use coworking for calls and networking. Paying for full-time access you use eight hours a day is optional; paying for reliability where it matters is not.

+ +

Exit criteria

+

If after two weeks you cannot name a concrete benefit (better calls, better leads, better focus), cancel. Sunk cost is not a strategy.

diff --git a/deploy/wordpress/content/posts/batch2/24-networking-without-burnout.html b/deploy/wordpress/content/posts/batch2/24-networking-without-burnout.html new file mode 100644 index 0000000..b983e7d --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/24-networking-without-burnout.html @@ -0,0 +1,13 @@ +

Networking is valuable until it becomes another full-time job. Nomads need social systems that create opportunity without destroying focus.

+ +

Choose a networking budget

+

Decide how many evenings per week you will allocate to events. Write it down. Unlimited social calendars quietly erase deep work.

+ +

Quality over volume

+

Two thoughtful conversations beat ten shallow introductions. Ask better questions: what are people building, what skills they need, what cities they trust for work.

+ +

Follow-up system

+

Same-day notes in your phone: name, context, next step. A short message within 24 hours outperforms collecting contacts you never reopen.

+ +

Protect recovery

+

Introverts and extroverts both need recovery, just on different schedules. If your work quality drops after social weeks, adjust the budget—not your standards.

diff --git a/deploy/wordpress/content/posts/batch2/25-backup-and-recovery.html b/deploy/wordpress/content/posts/batch2/25-backup-and-recovery.html new file mode 100644 index 0000000..c612a40 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/25-backup-and-recovery.html @@ -0,0 +1,18 @@ +

Hardware failure abroad is a predictable event. Treat backups as part of your product delivery system.

+ +

Minimum viable redundancy

+
    +
  • Cloud sync for active project files
  • +
  • Encrypted offline backup of critical documents
  • +
  • Password manager with offline access
  • +
  • A written restore checklist you have tested once
  • +
+ +

Laptop risk controls

+

Keep a thin secondary device if your income is device-critical—even a tablet that can handle docs and calls can bridge a week. Know where you can buy chargers locally.

+ +

Test restores, not only backups

+

Backups that have never been restored are stories. Once a quarter, restore a sample folder on a clean machine or profile and time how long recovery takes.

+ +

Incident communication

+

Have a template message for clients: what happened, what still works, when you will next update. Calm process protects reputation while you rebuild the stack.

diff --git a/deploy/wordpress/content/posts/batch2/26-seasonal-strategy.html b/deploy/wordpress/content/posts/batch2/26-seasonal-strategy.html new file mode 100644 index 0000000..2f2eb66 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/26-seasonal-strategy.html @@ -0,0 +1,17 @@ +

Seasoned nomads plan seasons, not only cities. Weather, crowds, pricing, and client calendars interact. A seasonal strategy reduces random stress.

+ +

Map the year in themes

+
    +
  • Deep-work seasons: quieter cities, fewer events, stronger routines
  • +
  • Network seasons: hubs where your industry gathers
  • +
  • Recovery seasons: nature or slower towns after intense delivery
  • +
+ +

Watch price and crowd cycles

+

Peak tourism can raise housing costs and noise. Shoulder seasons often improve work conditions even when weather is imperfect. Optimize for output, not only for beaches.

+ +

Align with client reality

+

If your clients have heavy quarters, do not schedule complex visa runs in the same weeks. Travel friction compounds business friction.

+ +

Review quarterly

+

Every three months, ask what the last season taught you about sleep, income, and energy. Update next season’s plan with evidence, not vibes.

diff --git a/deploy/wordpress/content/posts/batch2/27-client-communication.html b/deploy/wordpress/content/posts/batch2/27-client-communication.html new file mode 100644 index 0000000..33ac100 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/27-client-communication.html @@ -0,0 +1,18 @@ +

Clear client communication is a competitive advantage when your timezone and workspace change weekly. Templates reduce emotional labor and missed expectations.

+ +

Core templates to keep

+
    +
  • Kickoff: working hours, response SLAs, tools, and escalation path
  • +
  • Weekly update: progress, blockers, next decisions needed
  • +
  • Travel transition: dates, overlap hours, backup connectivity
  • +
  • Incident note: outage or delay with ETA for the next update
  • +
+ +

Write for async first

+

Assume recipients will read without a meeting. Put decisions and deadlines at the top. Attach links, not mysteries. Ambiguity creates meetings; clarity removes them.

+ +

Timezone etiquette

+

State your overlap window in the client’s timezone. Avoid “ASAP.” Replace it with a timestamp. Professional boundaries are easier to respect when they are specific.

+ +

Keep a living doc

+

Store templates in one note and improve them after each awkward thread. Your future self in another airport will thank you.

diff --git a/deploy/wordpress/content/posts/batch2/28-personal-knowledge-base.html b/deploy/wordpress/content/posts/batch2/28-personal-knowledge-base.html new file mode 100644 index 0000000..8ba37c8 --- /dev/null +++ b/deploy/wordpress/content/posts/batch2/28-personal-knowledge-base.html @@ -0,0 +1,18 @@ +

Travel creates information chaos: bookings, visas, client notes, city tips. A lightweight personal knowledge base keeps decisions fast.

+ +

Capture only what you will reuse

+

Useful notes include: housing checklists, SIM providers that worked, clinic locations, cafe call quality, and city-specific gotchas. Skip collecting generic listicles you will never reread.

+ +

A simple structure

+
    +
  • Cities: one page per destination
  • +
  • Systems: packing, money, health, work setup
  • +
  • Clients: preferences and communication norms
  • +
  • Templates: messages and checklists
  • +
+ +

Weekly maintenance

+

Spend ten minutes moving inbox notes into the right page. Unsorted captures rot. Sorted captures compound.

+ +

Offline access

+

Ensure critical pages work without internet. Airports and rural buses are poor moments to discover your wiki needs a login you cannot complete.

diff --git a/deploy/wordpress/content/privacy-policy.html b/deploy/wordpress/content/privacy-policy.html new file mode 100644 index 0000000..55ee6c7 --- /dev/null +++ b/deploy/wordpress/content/privacy-policy.html @@ -0,0 +1,38 @@ +

Last updated: August 16, 2026

+

Nomadro (“we”, “us”) operates https://nomadro.com. This Privacy Policy explains what information we collect, how we use it, and your choices. It is written to support transparency for readers and advertising partners such as Google AdSense.

+

Information we collect

+
    +
  • Information you provide — for example when you email hello@nomadro.com (name, email address, message content).
  • +
  • Technical data — IP address, browser type, device type, referring pages, approximate location derived from IP, and similar log data.
  • +
  • Cookies and similar technologies — used for site functionality, preferences, analytics, and advertising.
  • +
+

How we use information

+
    +
  • To operate, secure, and improve the website
  • +
  • To respond to inquiries
  • +
  • To understand aggregate traffic and content performance
  • +
  • To show ads through partners such as Google AdSense when enabled
  • +
+

Advertising and Google AdSense

+

We use Google AdSense and related Google advertising services. Third-party vendors, including Google, use cookies to serve ads based on a user’s prior visits to this or other websites.

+

Google’s use of advertising cookies enables it and its partners to serve ads based on your visit to this site and/or other sites on the Internet. You may opt out of personalized advertising by visiting Google Ads Settings. You can also visit www.aboutads.info for more information about interest-based advertising opt-outs.

+

For more details, see Google’s policies on advertising cookies and partner sites.

+

Analytics

+

We may use analytics tools (including Google Analytics via Site Kit or similar) to understand how visitors use the site. These tools may process IP addresses and usage events.

+

Data sharing

+

We do not sell personal information. We may share data with service providers that help us host, secure, analyze, or monetize the site, and when required by law.

+

Data retention

+

We retain information only as long as needed for the purposes described above, unless a longer period is required by law.

+

Your choices

+
    +
  • You can disable cookies in your browser settings.
  • +
  • You can request access, correction, or deletion of personal data you sent us by emailing hello@nomadro.com.
  • +
+

Children

+

This site is not directed to children under 13, and we do not knowingly collect personal information from children.

+

International visitors

+

Our servers may be located outside your country. By using the site, you understand that your information may be processed in other jurisdictions.

+

Changes

+

We may update this policy from time to time. The “Last updated” date at the top will change when we do.

+

Contact

+

Questions about this policy: hello@nomadro.com.

diff --git a/deploy/wordpress/content/terms.html b/deploy/wordpress/content/terms.html new file mode 100644 index 0000000..c54fa72 --- /dev/null +++ b/deploy/wordpress/content/terms.html @@ -0,0 +1,20 @@ +

Last updated: August 16, 2026

+

Welcome to Nomadro. By accessing https://nomadro.com, you agree to these Terms of Service.

+

Use of the site

+

Content on Nomadro is for general informational and educational purposes. You agree not to misuse the site, attempt unauthorized access, scrape content at abusive scale, or interfere with normal operations.

+

No professional advice

+

Articles about travel, remote work, tools, income, visas, tax, health, or finance are not personalized legal, tax, immigration, investment, or medical advice. Always verify requirements for your own situation and consult qualified professionals when needed.

+

Intellectual property

+

Unless otherwise noted, site text, branding, and original media belong to Nomadro or its licensors. You may share links and short quotations with attribution. Do not republish full articles without permission.

+

Third-party links and services

+

We may link to third-party websites, tools, or partners. We are not responsible for their content, policies, or practices. Purchases or accounts you create with third parties are between you and those providers.

+

Advertising

+

The site may display advertisements, including through Google AdSense. Ad labeling and partner practices follow applicable partner policies.

+

Disclaimer of warranties

+

The site is provided “as is” and “as available.” We do not warrant uninterrupted availability, error-free content, or fitness for a particular purpose.

+

Limitation of liability

+

To the maximum extent permitted by law, Nomadro is not liable for indirect, incidental, consequential, or special damages arising from your use of the site.

+

Changes

+

We may update these terms. Continued use after changes means you accept the updated terms.

+

Contact

+

Questions: hello@nomadro.com.

diff --git a/deploy/wordpress/docker-compose.yml b/deploy/wordpress/docker-compose.yml new file mode 100644 index 0000000..b2e52e1 --- /dev/null +++ b/deploy/wordpress/docker-compose.yml @@ -0,0 +1,69 @@ +# Nomadro WordPress (AdSense English blog) +# Binds to 127.0.0.1:8088 — Caddy terminates TLS and reverse-proxies. + +services: + db: + image: mariadb:11.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + + wordpress: + image: wordpress:6.8-php8.2-apache + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:8088:80" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD} + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: | + define('WP_HOME', 'https://nomadro.com'); + define('WP_SITEURL', 'https://nomadro.com'); + if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $$_SERVER['HTTPS'] = 'on'; + } + define('FORCE_SSL_ADMIN', true); + volumes: + - wp_data:/var/www/html + + wpcli: + image: wordpress:cli-php8.2 + depends_on: + - wordpress + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD} + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: | + define('WP_HOME', 'https://nomadro.com'); + define('WP_SITEURL', 'https://nomadro.com'); + if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $$_SERVER['HTTPS'] = 'on'; + } + volumes: + - wp_data:/var/www/html + - ./content:/content:ro + user: "33:33" + entrypoint: ["wp"] + command: ["--info"] + profiles: ["tools"] + +volumes: + db_data: + wp_data: diff --git a/deploy/wordpress/install-docker.sh b/deploy/wordpress/install-docker.sh new file mode 100644 index 0000000..7443c84 --- /dev/null +++ b/deploy/wordpress/install-docker.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Install Docker Engine + Compose plugin (Ubuntu) +set -euo pipefail + +if command -v docker >/dev/null 2>&1; then + echo "Docker already installed: $(docker --version)" + exit 0 +fi + +apt-get update -qq +apt-get install -y -qq ca-certificates curl gnupg +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +. /etc/os-release +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + +apt-get update -qq +apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +systemctl enable --now docker +docker --version +docker compose version diff --git a/deploy/wordpress/mu-plugins/nomadro-adsense.php b/deploy/wordpress/mu-plugins/nomadro-adsense.php new file mode 100644 index 0000000..a4d500d --- /dev/null +++ b/deploy/wordpress/mu-plugins/nomadro-adsense.php @@ -0,0 +1,157 @@ +' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + // Deploy replaces REPLACE_UMAMI_WEBSITE_ID with a UUID; only emit when set. + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', NOMADRO_UMAMI_WEBSITE_ID)) { + echo '' . "\n"; + } +}, 1); + +/** Organization + WebSite JSON-LD for trust/SEO. */ +add_action('wp_head', static function (): void { + if (is_admin()) { + return; + } + $data = [ + '@context' => 'https://schema.org', + '@graph' => [ + [ + '@type' => 'Organization', + '@id' => home_url('/#organization'), + 'name' => 'Nomadro', + 'url' => home_url('/'), + 'email' => 'hello@nomadro.com', + 'description' => 'Practical digital nomad guides for remote work, travel systems, and location-independent income.', + ], + [ + '@type' => 'WebSite', + '@id' => home_url('/#website'), + 'url' => home_url('/'), + 'name' => 'Nomadro', + 'publisher' => ['@id' => home_url('/#organization')], + 'inLanguage' => 'en-US', + ], + ], + ]; + echo '' . "\n"; +}, 5); + +/** Serve ads.txt from WordPress root reliably. */ +add_action('init', static function (): void { + $uri = isset($_SERVER['REQUEST_URI']) ? strtok($_SERVER['REQUEST_URI'], '?') : ''; + if ($uri === '/ads.txt') { + status_header(200); + header('Content-Type: text/plain; charset=utf-8'); + header('X-Robots-Tag: noindex'); + echo "google.com, pub-8461145959530971, DIRECT, f08c47fec0942fa0\n"; + exit; + } +}); + +/** Editorial CSS: readable articles, image presentation, footer trust links. */ +add_action('wp_enqueue_scripts', static function (): void { + $css = <<<'CSS' +:root { --nomadro-accent:#6d28d9; } +body { -webkit-font-smoothing: antialiased; } +.nomadro-hero-figure img, +.wp-block-post-featured-image img, +.wp-post-image { + border-radius: 14px; + width: 100%; + height: auto; +} +.nomadro-hero-figure figcaption, +figure.wp-block-image figcaption { + color: #667085; + font-size: 0.92rem; + margin-top: 0.5rem; +} +.nomadro-callout { + border-left: 4px solid var(--nomadro-accent) !important; + background: #f5f3ff !important; + border-radius: 8px; + padding: 14px 18px !important; + margin: 1.5rem 0 !important; +} +.entry-content, .wp-block-post-content { + font-size: 1.0625rem; + line-height: 1.75; +} +.entry-content h2, .wp-block-post-content h2 { + margin-top: 2rem; + line-height: 1.3; +} +.wp-block-post-excerpt, .entry-summary { + color: #475467; +} +.nomadro-trust-footer { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + justify-content: center; + padding: 1.5rem 1rem 2rem; + font-size: 0.95rem; +} +.nomadro-trust-footer a { color: #344054; text-decoration: underline; } +CSS; + wp_register_style('nomadro-adsense-opt', false, [], '1.0.0'); + wp_enqueue_style('nomadro-adsense-opt'); + wp_add_inline_style('nomadro-adsense-opt', $css); +}, 20); + +/** Persistent trust links in footer (AdSense reviewers look for these). */ +add_action('wp_footer', static function (): void { + $links = [ + home_url('/about/') => 'About', + home_url('/contact/') => 'Contact', + home_url('/privacy-policy/') => 'Privacy Policy', + home_url('/terms/') => 'Terms of Service', + home_url('/disclaimer/') => 'Disclaimer', + ]; + echo ''; +}, 5); + +/** Harden & hygiene. */ +remove_action('wp_head', 'wp_generator'); +add_filter('the_generator', '__return_empty_string'); +add_filter('xmlrpc_enabled', '__return_false'); + +/** Disable comments/pings by default for cleaner review surface. */ +add_filter('comments_open', '__return_false', 20, 2); +add_filter('pings_open', '__return_false', 20, 2); +add_filter('comments_array', static function () { + return []; +}, 10, 2); + +/** Better author label in bylines. */ +add_filter('the_author', static function ($author) { + return 'Nomadro Editorial'; +}); +add_filter('get_the_author_display_name', static function () { + return 'Nomadro Editorial'; +}); diff --git a/deploy/wordpress/remote_provision.py b/deploy/wordpress/remote_provision.py new file mode 100644 index 0000000..16ddbfc --- /dev/null +++ b/deploy/wordpress/remote_provision.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Upload WordPress deploy bundle and provision on the Nomadro server.""" + +from __future__ import annotations + +import os +import secrets +import string +import sys +import time +from pathlib import Path + +import paramiko + +HOST = "107.173.30.245" +USER = "root" +PASSWORD = os.environ.get("NOMADRO_SSH_PASS", "") +REMOTE_DIR = "/var/www/nomadro-wp" +LOCAL_DIR = Path(__file__).resolve().parent + + +def gen_pass(n: int = 24) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +def safe_print(text: str) -> None: + try: + print(text) + except UnicodeEncodeError: + print(text.encode("utf-8", errors="replace").decode("utf-8", errors="replace")) + + +def run(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> str: + print(f"\n$ {cmd}") + stdin, stdout, stderr = ssh.exec_command(cmd, timeout=timeout, get_pty=True) + out = stdout.read().decode(errors="replace") + err = stderr.read().decode(errors="replace") + code = stdout.channel.recv_exit_status() + if out: + safe_print(out[-4000:]) + if err.strip(): + safe_print(err[-2000:]) + if code != 0: + raise RuntimeError(f"Command failed ({code}): {cmd}") + return out + + +def sftp_mkdirs(sftp: paramiko.SFTPClient, remote: str) -> None: + parts = remote.strip("/").split("/") + cur = "" + for part in parts: + cur += "/" + part + try: + sftp.stat(cur) + except FileNotFoundError: + sftp.mkdir(cur) + + +def upload_tree(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None: + sftp_mkdirs(sftp, remote) + for path in local.rglob("*"): + if path.name.startswith(".env") or path.name.endswith(".pyc"): + continue + if path.name == "remote_provision.py": + continue + rel = path.relative_to(local).as_posix() + target = f"{remote}/{rel}" + if path.is_dir(): + sftp_mkdirs(sftp, target) + else: + sftp_mkdirs(sftp, str(Path(target).parent).replace("\\", "/")) + print(f"put {rel}") + sftp.put(str(path), target) + + +def main() -> int: + if not PASSWORD: + print("Set NOMADRO_SSH_PASS", file=sys.stderr) + return 1 + + mysql_pass = gen_pass() + mysql_root = gen_pass() + wp_admin = "Xiao4669805@" + wp_user = "hackrobot" + wp_email = "admin@nomadro.com" + + env_text = ( + f"MYSQL_PASSWORD={mysql_pass}\n" + f"MYSQL_ROOT_PASSWORD={mysql_root}\n" + f"WP_ADMIN_USER={wp_user}\n" + f"WP_ADMIN_PASSWORD={wp_admin}\n" + f"WP_ADMIN_EMAIL={wp_email}\n" + ) + + secrets_path = LOCAL_DIR / ".env.generated" + secrets_path.write_text(env_text, encoding="utf-8") + print(f"Wrote local secrets to {secrets_path} (do not commit)") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + HOST, + username=USER, + password=PASSWORD, + timeout=30, + allow_agent=False, + look_for_keys=False, + ) + sftp = ssh.open_sftp() + + print("==> Upload wordpress bundle") + upload_tree(sftp, LOCAL_DIR, REMOTE_DIR) + with sftp.file(f"{REMOTE_DIR}/.env", "w") as f: + f.write(env_text) + sftp.chmod(f"{REMOTE_DIR}/install-docker.sh", 0o755) + sftp.chmod(f"{REMOTE_DIR}/bootstrap-wp.sh", 0o755) + sftp.close() + + run(ssh, f"bash {REMOTE_DIR}/install-docker.sh", timeout=900) + run(ssh, f"cd {REMOTE_DIR} && docker compose pull", timeout=900) + run(ssh, f"cd {REMOTE_DIR} && docker compose up -d", timeout=300) + + print("==> Wait for WP HTTP") + for _ in range(60): + stdin, stdout, stderr = ssh.exec_command( + "curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8088 || true" + ) + code = stdout.read().decode().strip() + if code.startswith("2") or code.startswith("3"): + print("WP HTTP", code) + break + time.sleep(3) + else: + raise RuntimeError("WordPress did not become ready on :8088") + + # Preserve extra caddy site imports already on server + run( + ssh, + f"cp {REMOTE_DIR}/Caddyfile.adsense /etc/caddy/Caddyfile && " + "caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy", + ) + + run(ssh, f"cd {REMOTE_DIR} && bash bootstrap-wp.sh", timeout=900) + + # Quick public check via local caddy + run(ssh, "curl -fsSI https://nomadro.com | head -20 || true") + run( + ssh, + "curl -fsS https://nomadro.com | tr '\\n' ' ' | grep -o '[^<]*' | head -1 || true", + ) + + print("\n=== CREDENTIALS (save now) ===") + print(f"WP admin user: {wp_user}") + print(f"WP admin pass: {wp_admin}") + print(f"WP admin URL: https://nomadro.com/wp-admin/") + print(f"Next.js portal: https://portal.nomadro.com (add DNS A -> {HOST})") + ssh.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())