diff --git a/.gitignore b/.gitignore index e43b0f9..7d4d483 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +deploy/rendered/ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..965574b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,237 @@ +# Production deployment — four EXO landings on one VPS + +Four independent landings, each on its own domain root, on a single reg.ru KVM VPS. +nginx serves the built SPA off disk; a small Express process per app handles only +`/api/*` and forwards leads to amoCRM. Releases are built on the server and swapped +in atomically via a `current` symlink. + +**Fill in `apps.conf` first** — it is the single source of truth for domains and ports. + +| App | Domain | Port | amoCRM tag | +|---|---|---|---| +| `fitnes` | `FITNES_DOMAIN` | 3000 | `fitness-landing` | +| `hotel` | `HOTEL_DOMAIN` | 3001 | `hotel-landing` | +| `medcenter` | `MEDCENTER_DOMAIN` | 3002 | `medcenter-landing` | +| `medcenterpersonal` | `MCPERSONAL_DOMAIN` | 3003 | `medcenter-person-landing` | + +## VPS requirements + +| | Minimum | Recommended | +|---|---|---| +| vCPU | 2 | 2–4 | +| RAM | 4 GB + 2 GB swap | 4–8 GB | +| Disk | 40 GB NVMe | 60–80 GB NVMe | +| Network | 1 IPv4, ≥100 Mbps | + IPv6 | +| Virtualization | **KVM** | KVM | + +Take the **KVM** line, not OpenVZ/LXC — the systemd sandboxing in `systemd/exo@.service` +and swap control need a real kernel. Moscow or SPb DC: leads are personal data of +Russian citizens, so 152-ФЗ wants them processed on RU infrastructure. + +Why these numbers: four Node processes ≈ 400 MB total; nginx ~30 MB; Ubuntu ~300 MB — +idle well under 1 GB. The only spike is a build (~0.5–1 GB, one app at a time; the +toolchain is native — TypeScript 7 in Go, Vite 8 on Rolldown, Tailwind's Rust oxide). +Disk: ~40 MB per pruned release, 3 kept × 4 apps ≈ 1.5 GB steady. + +## Software + +| Component | Version | +|---|---| +| Ubuntu | 24.04 LTS (Debian 12/13 fine) | +| Node.js | 24.x LTS — hard floor is `>=22.12` from transitive deps | +| npm | 11.x (bundled) | +| nginx | ≥1.24 — needs `http2`, `gzip_static`, `ssl`, all built in | +| certbot | `python3-certbot-nginx` | +| ufw, fail2ban, unattended-upgrades | distro | + +No database, no Redis, no Docker, no PM2. See §14 of the plan for why not Docker. + +## Provisioning + +### 1. Base OS + +```bash +apt update && apt full-upgrade -y +apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip +timedatectl set-timezone Europe/Moscow +dpkg-reconfigure --priority=low unattended-upgrades + +# Swap, if the plan doesn't provide it +fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab +echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf && sysctl --system +``` + +### 2. Node 24 + +```bash +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt install -y nodejs && node -v # v24.x +``` + +### 3. User, directories, repos + +```bash +adduser --system --group --home /srv/exo --shell /bin/bash exo +mkdir -p /srv/exo/{fitnes,hotel,medcenter,medcenterpersonal}/releases +mkdir -p /etc/exo /var/www/certbot +chown -R exo:exo /srv/exo +chmod 755 /srv/exo # www-data must traverse to reach dist/client +chmod 750 /etc/exo + +for app in fitnes hotel medcenter medcenterpersonal; do + sudo -u exo git clone --bare /srv/exo/$app/repo +done +``` + +All four clone the same repo; `exo-deploy` extracts one subdirectory each. + +### 4. Env files, per app + +Two files with different trust levels — see `env/app.env.example` and +`env/app.build.env.example` for annotated templates. + +```bash +install -m 640 -o root -g exo env/app.env.example /etc/exo/fitnes.env +install -m 644 env/app.build.env.example /etc/exo/fitnes.build.env +# then edit both: PORT, AMO_LONG_LIVED_TOKEN, AMO_LEAD_TAGS, VITE_SITE_URL +``` + +The split matters. `VITE_SITE_URL` is a **build-time HTML substitution** — if it is +absent when `vite build` runs, the literal `%VITE_SITE_URL%` ships into the canonical +tag and OG metadata (`exo-deploy` aborts if it detects this). Keeping the amoCRM token +in the 640 runtime file means it never lands in a release directory. `dotenv` does not +override variables already in the environment, so the release `.env` (VITE vars) and +systemd's `EnvironmentFile` (secrets) coexist and the secrets win. + +### 5. systemd + +```bash +install -m 644 systemd/exo@.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable exo@fitnes exo@hotel exo@medcenter exo@medcenterpersonal +``` + +Let the deploy user restart only its own units — `visudo -f /etc/sudoers.d/exo-deploy`: + +``` +exo ALL=(root) NOPASSWD: /usr/bin/systemctl restart exo@fitnes, \ + /usr/bin/systemctl restart exo@hotel, \ + /usr/bin/systemctl restart exo@medcenter, \ + /usr/bin/systemctl restart exo@medcenterpersonal +``` + +### 6. Firewall and log retention + +```bash +ufw default deny incoming && ufw default allow outgoing +ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable +``` + +Ports 3000–3003 are never opened, and the app binds `127.0.0.1` by default anyway +(`HOST` in `server/src/config.ts`). In `/etc/ssh/sshd_config.d/99-hardening.conf` set +`PasswordAuthentication no` and `PermitRootLogin no` — but install your key on a +sudo-capable non-root user *first*. + +On an amoCRM failure the server deliberately logs the whole lead payload so a real +lead is never lost. That puts names and phones in the journal, so bound it in +`/etc/systemd/journald.conf`: + +```ini +[Journal] +SystemMaxUse=500M +MaxRetentionSec=14day +``` + +### 7. nginx + +```bash +install -m 644 nginx/snippets/*.conf /etc/nginx/snippets/ +install -m 644 nginx/http-extras.conf /etc/nginx/conf.d/00-exo-http.conf + +bin/exo-render-nginx /etc/nginx/sites-available # after filling in apps.conf +ln -s /etc/nginx/sites-available/exo-*.conf /etc/nginx/sites-enabled/ +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx +``` + +### 8. DNS and TLS + +Order matters: nginx needs the port-80 vhost live before certbot can validate. + +1. Per domain at reg.ru DNS: `A @ → `, `A www → ` (+ `AAAA` if available). +2. Confirm: `dig +short @77.88.8.8`. +3. Comment out the two `443` blocks in each rendered vhost (they reference certs that + don't exist yet), then `nginx -t && systemctl reload nginx`. +4. Issue apex + www together, per domain: + ```bash + certbot certonly --webroot -w /var/www/certbot \ + -d -d www. --agree-tos -m --no-eff-email + ``` +5. Uncomment the `443` blocks, `nginx -t && systemctl reload nginx`. +6. Make renewal reload nginx: + ```bash + printf '#!/bin/sh\nsystemctl reload nginx\n' > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx + chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx + certbot renew --dry-run + ``` + +### 9. First deploy + +```bash +install -m 755 bin/exo-deploy /usr/local/bin/ +sudo -u exo exo-deploy medcenterpersonal main +``` + +## Runbook + +| Task | Command | +|---|---| +| Deploy | `sudo -u exo exo-deploy [ref]` | +| Rollback | `ln -sfn /srv/exo//releases/ /srv/exo//current && sudo systemctl restart exo@` | +| Tail logs | `journalctl -u exo@ -f` | +| All leads, last hour | `journalctl -u 'exo@*' --since '1 hour ago' \| grep '\[lead\]'` | +| Restart all | `systemctl restart 'exo@*'` | +| Check amoCRM wiring | `cd /srv/exo//current && npm run amo:check` | +| Rotate amoCRM token | edit `/etc/exo/*.env` → `systemctl restart 'exo@*'` → verify `"amo":true` | +| Renew certs | `certbot renew && systemctl reload nginx` | +| Disk usage | `du -sh /srv/exo/*/releases/*` | + +`exo-deploy` health-checks after the swap and **rolls back automatically** if the new +release fails to answer `/api/health`. + +## Monitoring + +Each app answers `GET /api/health` with `{ ok, amo, pipelineId }`. Point an off-box +uptime checker at `https:///api/health` for all four, alerting on non-200 **and +on `"amo":false`** — the latter is how an expired amoCRM token shows up, and systemd +cannot see it. `Restart=always` covers crashes. + +Log markers worth alerting on: `[lead] amoCRM submission failed`, `[lead] payload was:` +(a recoverable lead sitting in the journal), `[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN +are not set`. + +**The amoCRM token is the most likely future outage.** ~1 year TTL, fails as a 401 that +visitors see as a 502 with a phone number. Calendar a rotation ~11 months out. + +## Backups + +No database, and the code is in git, so the surface is small. Nightly tar, off the box: + +- `/etc/exo/` — the amoCRM token and per-app config +- `/etc/nginx/sites-available/`, `/etc/nginx/snippets/`, `/etc/nginx/conf.d/` +- `/etc/letsencrypt/` +- `/etc/systemd/system/exo@.service`, `/etc/sudoers.d/exo-deploy` + +Also enable reg.ru VPS snapshots — a full-image restore beats rebuilding under pressure. + +## Known constraints + +- **One process per app.** `server/src/rate-limit.ts` is an in-memory fixed-window + limiter, single-process by design. Horizontal scaling needs a shared store first. + Not a concern at landing-page traffic; nginx `limit_req` is the second layer. +- **No CI.** Deployment is a manual `exo-deploy`. A GitHub Actions job that SSHes and + runs it is a natural follow-up once the flow is proven. +- **Still open before launch** (not deployment blockers): no analytics snippet is + installed although `src/lib/lead.ts` pushes `dataLayer` events; and the forms show + implicit 152-ФЗ consent text with no link to a published privacy policy. diff --git a/deploy/apps.conf b/deploy/apps.conf new file mode 100644 index 0000000..d9b934a --- /dev/null +++ b/deploy/apps.conf @@ -0,0 +1,8 @@ +# Single source of truth for the deployment. Edit the domains, nothing else. +# Consumed by bin/exo-render-nginx and referenced by README.md. +# +# app|domain|port|amo_lead_tag +fitnes|FITNES_DOMAIN|3000|fitness-landing +hotel|HOTEL_DOMAIN|3001|hotel-landing +medcenter|MEDCENTER_DOMAIN|3002|medcenter-landing +medcenterpersonal|MCPERSONAL_DOMAIN|3003|medcenter-person-landing diff --git a/deploy/bin/exo-deploy b/deploy/bin/exo-deploy new file mode 100755 index 0000000..fa02e9f --- /dev/null +++ b/deploy/bin/exo-deploy @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Build one landing from git into a fresh release directory and swap it in. +# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user. +# +# sudo -u exo exo-deploy medcenter # deploys origin/main +# sudo -u exo exo-deploy medcenter my-branch +set -Eeuo pipefail + +APP="${1:?usage: exo-deploy [git-ref]}" +REF="${2:-main}" +BASE="/srv/exo/$APP" +ENVFILE="/etc/exo/$APP.env" +BUILDENV="/etc/exo/$APP.build.env" +KEEP=3 +REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)" + +for f in "$ENVFILE" "$BUILDENV"; do + [[ -r $f ]] || { echo "FATAL: cannot read $f"; exit 1; } +done +[[ -d $BASE/repo ]] || { echo "FATAL: no bare repo at $BASE/repo"; exit 1; } + +PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")" +[[ -n $PORT ]] || { echo "FATAL: PORT not set in $ENVFILE"; exit 1; } + +echo "==> fetching $REF" +git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*' +git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null + +echo "==> extracting $APP/ into $REL" +mkdir -p "$REL" +git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1 +[[ -f $REL/package.json ]] || { echo "FATAL: $APP/ not found at $REF"; rm -rf "$REL"; exit 1; } + +# 2.6-3.9 MB of base64-embedded reference HTML that is never served. +rm -rf "$REL/legacy" + +# Public build-time vars only. The amoCRM token stays in $ENVFILE, which +# systemd injects at runtime; dotenv does not override real env vars, so the +# two never collide. +cp "$BUILDENV" "$REL/.env" + +cd "$REL" + +# devDependencies are REQUIRED here: vite, typescript and tailwindcss all live +# there and `npm run build` needs them. --omit=dev breaks the build. +echo "==> npm ci" +npm ci --no-audit --no-fund + +echo "==> npm run build" +npm run build + +# Fail loudly rather than shipping broken SEO tags or an empty bundle. +[[ -s dist/client/index.html ]] || { echo "FATAL: dist/client/index.html missing or empty"; exit 1; } +if grep -q '%VITE_SITE_URL%' dist/client/index.html; then + echo "FATAL: VITE_SITE_URL was not substituted — check $BUILDENV"; exit 1 +fi + +# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release. +echo "==> pruning devDependencies" +npm prune --omit=dev + +# Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose. +find dist/client -type f \ + \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \ + -exec gzip -9 -k -f {} + + +echo "==> swapping $BASE/current -> $REL" +PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)" +ln -sfn "$REL" "$BASE/current.tmp" +mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic + +sudo systemctl restart "exo@$APP" + +echo "==> waiting for health on 127.0.0.1:$PORT" +for _ in $(seq 1 20); do + if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi + sleep 1 +done +HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || { + echo "FATAL: health check failed. Rolling back." + [[ -n $PREV ]] && { ln -sfn "$PREV" "$BASE/current.tmp"; mv -Tf "$BASE/current.tmp" "$BASE/current"; sudo systemctl restart "exo@$APP"; } + exit 1 +} +echo " $HEALTH" +grep -q '"ok":true' <<<"$HEALTH" || { echo "FATAL: health not ok"; exit 1; } +grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE." + +# Prune old releases, never the live one. +CURRENT="$(readlink -f "$BASE/current")" +ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do + [[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue + rm -rf "$old" +done + +echo "==> deployed $APP @ $REF ($(git -C "$BASE/repo" rev-parse --short "$REF")) -> $REL" diff --git a/deploy/bin/exo-render-nginx b/deploy/bin/exo-render-nginx new file mode 100755 index 0000000..69ed4de --- /dev/null +++ b/deploy/bin/exo-render-nginx @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Render one nginx vhost per app from deploy/apps.conf + nginx/site.conf.template. +# +# deploy/bin/exo-render-nginx # writes to ./rendered/ +# deploy/bin/exo-render-nginx /etc/nginx/sites-available +set -Eeuo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${1:-$HERE/rendered}" +TPL="$HERE/nginx/site.conf.template" +mkdir -p "$OUT" + +while IFS='|' read -r app domain port tag; do + [[ -z ${app:-} || $app == \#* ]] && continue + if [[ $domain == *_DOMAIN ]]; then + echo "SKIP $app: domain is still the placeholder '$domain' — edit deploy/apps.conf" >&2 + continue + fi + sed -e "s/{{APP}}/$app/g" -e "s/{{DOMAIN}}/$domain/g" -e "s/{{PORT}}/$port/g" \ + "$TPL" > "$OUT/exo-$app.conf" + echo "wrote $OUT/exo-$app.conf ($domain -> 127.0.0.1:$port)" +done < "$HERE/apps.conf" diff --git a/deploy/env/app.build.env.example b/deploy/env/app.build.env.example new file mode 100644 index 0000000..f84da17 --- /dev/null +++ b/deploy/env/app.build.env.example @@ -0,0 +1,11 @@ +# Install as /etc/exo/.build.env — chmod 644 +# Public, build-time only. exo-deploy copies this to /.env before +# `vite build`, so no secret may ever appear here. + +# Substituted into %VITE_SITE_URL% in index.html: canonical, og:url, og:image +# and the JSON-LD url. If it is missing, the literal placeholder ships and +# exo-deploy aborts. +VITE_SITE_URL=https://FITNES_DOMAIN + +# Each app sits at its own domain root, so this stays "/". +VITE_BASE_PATH=/ diff --git a/deploy/env/app.env.example b/deploy/env/app.env.example new file mode 100644 index 0000000..86840fd --- /dev/null +++ b/deploy/env/app.env.example @@ -0,0 +1,26 @@ +# Install as /etc/exo/.env — chown root:exo, chmod 640 +# Runtime config for the Node process. systemd injects this via EnvironmentFile. +# CONTAINS A SECRET: never commit a filled-in copy. + +NODE_ENV=production +HOST=127.0.0.1 +# Per deploy/apps.conf: fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003 +PORT=3000 + +# amoCRM account subdomain. "exotherapy", "exotherapy.amocrm.ru" and a full URL +# are all accepted (server/src/config.ts). +AMO_SUBDOMAIN=zvvamoexotherapyru + +# Long-lived token, valid ~1 year. Paste out of band; rotate before it expires +# (its failure mode is a 401 surfaced to visitors as a 502). +AMO_LONG_LIVED_TOKEN= + +AMO_PIPELINE_ID=10980758 +AMO_RESPONSIBLE_USER_ID= + +# Per-landing tag, per deploy/apps.conf. +AMO_LEAD_TAGS=fitness-landing + +# Optional, undocumented in the app .env.example files: overrides the amoCRM +# domain (default amocrm.ru). +# AMO_DOMAIN=amocrm.ru diff --git a/deploy/nginx/http-extras.conf b/deploy/nginx/http-extras.conf new file mode 100644 index 0000000..1b36437 --- /dev/null +++ b/deploy/nginx/http-extras.conf @@ -0,0 +1,19 @@ +# Paste into the http {} block of /etc/nginx/nginx.conf, or drop into +# /etc/nginx/conf.d/00-exo-http.conf. + +gzip on; +gzip_vary on; +gzip_comp_level 6; +gzip_min_length 1024; +gzip_proxied any; +# .webp and .woff2 are deliberately absent — already compressed. +gzip_types text/plain text/css application/javascript application/json image/svg+xml; + +# Serves the .gz files exo-deploy precompresses next to each asset. +gzip_static on; + +server_tokens off; + +# Must stay off: nginx would otherwise cache the resolved path behind the +# `current` symlink and keep serving the previous release after a deploy. +open_file_cache off; diff --git a/deploy/nginx/site.conf.template b/deploy/nginx/site.conf.template new file mode 100644 index 0000000..167ac15 --- /dev/null +++ b/deploy/nginx/site.conf.template @@ -0,0 +1,83 @@ +# Rendered by deploy/bin/exo-render-nginx from deploy/apps.conf. +# Placeholders: {{APP}} {{DOMAIN}} {{PORT}} +# +# Layout: nginx serves the built SPA straight off disk and proxies only /api/*. +# A restarting or crashed Node process therefore takes down the form, not the page. + +limit_req_zone $binary_remote_addr zone=leads_{{APP}}:10m rate=10r/m; + +# --- ACME challenge + http -> https --------------------------------------- +server { + listen 80; + listen [::]:80; + server_name {{DOMAIN}} www.{{DOMAIN}}; + + # Must come before any try_files fallback, or certbot's challenge 404s. + location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; } + + location / { return 301 https://{{DOMAIN}}$request_uri; } +} + +# --- www -> apex ----------------------------------------------------------- +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name www.{{DOMAIN}}; + + ssl_certificate /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem; + include /etc/nginx/snippets/ssl-params.conf; + + return 301 https://{{DOMAIN}}$request_uri; +} + +# --- the site -------------------------------------------------------------- +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name {{DOMAIN}}; + + ssl_certificate /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem; + include /etc/nginx/snippets/ssl-params.conf; + + root /srv/exo/{{APP}}/current/dist/client; + index index.html; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + # Vite fingerprints everything under /assets — cache for a year. + location ^~ /assets/ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + # The one file that must never be cached, or a deploy goes unseen. + location = /index.html { add_header Cache-Control "no-cache" always; } + + location = /robots.txt { access_log off; } + location = /sitemap.xml { access_log off; } + + location = /api/health { + access_log off; + proxy_pass http://127.0.0.1:{{PORT}}; + include /etc/nginx/snippets/proxy-params.conf; + } + + # Second layer of rate limiting: the app's own limiter is in-memory and + # resets on restart (server/src/rate-limit.ts). + location /api/ { + limit_req zone=leads_{{APP}} burst=5 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:{{PORT}}; + include /etc/nginx/snippets/proxy-params.conf; + } + + location / { try_files $uri $uri/ /index.html; } +} diff --git a/deploy/nginx/snippets/proxy-params.conf b/deploy/nginx/snippets/proxy-params.conf new file mode 100644 index 0000000..3719336 --- /dev/null +++ b/deploy/nginx/snippets/proxy-params.conf @@ -0,0 +1,15 @@ +# /etc/nginx/snippets/proxy-params.conf +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +# OVERWRITE, not $proxy_add_x_forwarded_for: a client-supplied X-Forwarded-For +# must never reach the app, or the lead rate limiter can be side-stepped. +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Forwarded-Proto $scheme; + +proxy_connect_timeout 5s; +proxy_send_timeout 60s; +# 60s, not the usual 30s: server/src/amocrm.ts retries 3x with a 15s per-attempt +# timeout plus backoff. A shorter timeout would cut off a lead that was about to +# succeed on its third attempt. +proxy_read_timeout 60s; diff --git a/deploy/nginx/snippets/ssl-params.conf b/deploy/nginx/snippets/ssl-params.conf new file mode 100644 index 0000000..7e95f13 --- /dev/null +++ b/deploy/nginx/snippets/ssl-params.conf @@ -0,0 +1,11 @@ +# /etc/nginx/snippets/ssl-params.conf +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers off; +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; +ssl_stapling on; +ssl_stapling_verify on; +# Yandex DNS — reachable from Russian datacentres, unlike 8.8.8.8 at times. +resolver 77.88.8.8 77.88.8.1 valid=300s; +resolver_timeout 5s; diff --git a/deploy/systemd/exo@.service b/deploy/systemd/exo@.service new file mode 100644 index 0000000..df5ac74 --- /dev/null +++ b/deploy/systemd/exo@.service @@ -0,0 +1,36 @@ +# /etc/systemd/system/exo@.service +# One template unit for all four landings: systemctl enable --now exo@fitnes +[Unit] +Description=EXO landing API (%i) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=exo +Group=exo +WorkingDirectory=/srv/exo/%i/current +EnvironmentFile=/etc/exo/%i.env +# The server is TypeScript run directly by tsx — there is no server build step. +ExecStart=/srv/exo/%i/current/node_modules/.bin/tsx server/src/index.ts +Restart=always +RestartSec=2 +SyslogIdentifier=exo-%i + +# Sandbox. The process only reads from /srv/exo and talks HTTPS to amocrm.ru. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictSUIDSGID=true +LockPersonality=true +# Backstop, not a tuning target: expected RSS is 60-100 MB. +MemoryMax=512M +TasksMax=256 + +[Install] +WantedBy=multi-user.target