Files
exodevices/deploy/README.md
T
Yuriy PanovandClaude Opus 5 9736024e60 deploy: fix nginx security headers dropped by add_header inheritance
Found while verifying the first real provision: none of the four
security headers reached the browser on any HTML page.

nginx's add_header inheritance is all-or-nothing — a location that sets
any add_header of its own discards every header inherited from the
server block. "/" resolves through try_files to `location = /index.html`,
which sets Cache-Control, so HSTS, X-Content-Type-Options,
X-Frame-Options and Referrer-Policy were silently dropped exactly where
they matter. /assets/ lost them the same way.

Move the four into snippets/security-headers.conf and include it in the
server block and in both locations that add a header of their own.

Also from the same provision run:
- ssl_stapling is dead config now that Let's Encrypt certificates carry
  no OCSP responder URL; it only logs a warning per cert on each reload;
- README step 3 chmod'ed /etc/exo to 750 but never set its group, so the
  exo user could not traverse it and exo-deploy died on "cannot read
  /etc/exo/<app>.env";
- README step 7 dropped http-extras.conf into conf.d without disabling
  the same directives in Debian's stock nginx.conf, and nginx refuses to
  start on a duplicate gzip / server_tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:56:16 +06:00

9.5 KiB
Raw Blame History

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.

apps.conf is the single source of truth for domains and ports — check it before provisioning.

App Domain Port amoCRM tag
fitnes fitness.exorecovery.ru 3000 fitness-landing
hotel hotel.exorecovery.ru 3001 hotel-landing
medcenterphysio physio.exorecovery.ru 3002 physio-landing
medcenterstart start.exorecovery.ru 3003 start-landing

VPS requirements

Minimum Recommended
vCPU 2 24
RAM 4 GB + 2 GB swap 48 GB
Disk 40 GB NVMe 6080 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.51 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

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

curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs && node -v   # v24.x

3. User, directories, repos

adduser --system --group --home /srv/exo --shell /bin/bash exo
mkdir -p /srv/exo/{fitnes,hotel,medcenterphysio,medcenterstart}/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
chown root:exo /etc/exo     # without the group, exo cannot traverse it at 750
chmod 750 /etc/exo

for app in fitnes hotel medcenterphysio medcenterstart; do
  sudo -u exo git clone --bare <REPO_URL> /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.

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

install -m 644 systemd/exo@.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable exo@fitnes exo@hotel exo@medcenterphysio exo@medcenterstart

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@medcenterphysio, \
  /usr/bin/systemctl restart exo@medcenterstart

6. Firewall and log retention

ufw default deny incoming && ufw default allow outgoing
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable

Ports 30003003 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:

[Journal]
SystemMaxUse=500M
MaxRetentionSec=14day

7. nginx

install -m 644 nginx/snippets/*.conf /etc/nginx/snippets/
install -m 644 nginx/http-extras.conf /etc/nginx/conf.d/00-exo-http.conf

# Debian's stock nginx.conf sets some of the same http{} directives (gzip,
# server_tokens), and nginx refuses to start on a duplicate. Comment out every
# stock directive that 00-exo-http.conf now owns.
for d in $(grep -oE '^[a-z_]+' /etc/nginx/conf.d/00-exo-http.conf | sort -u); do
  sed -i -E "s@^([[:space:]]*)($d[[:space:]]+[^;]*;)@\\1# \\2@" /etc/nginx/nginx.conf
done

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 @ → <VPS IPv4>, A www → <VPS IPv4> (+ AAAA if available).
  2. Confirm: dig +short <domain> @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:
    certbot certonly --webroot -w /var/www/certbot \
      -d <domain> -d www.<domain> --agree-tos -m <admin-email> --no-eff-email
    
  5. Uncomment the 443 blocks, nginx -t && systemctl reload nginx.
  6. Make renewal reload nginx:
    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

install -m 755 bin/exo-deploy /usr/local/bin/
sudo -u exo exo-deploy medcenterphysio main

Runbook

Task Command
Deploy sudo -u exo exo-deploy <app> [ref]
Rollback ln -sfn /srv/exo/<app>/releases/<older> /srv/exo/<app>/current && sudo systemctl restart exo@<app>
Tail logs journalctl -u exo@<app> -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/<app>/current && npm run amo:check
Rotate amoCRM token edit /etc/exo/*.envsystemctl 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://<domain>/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.