Until now a release could only come from the bare repo on the VPS, so code that never reached this server's git remote could not be deployed at all. exo-deploy now also takes a zip of the landing source directories: exo-deploy --zip /tmp/exo-20260101.zip medcenterphysio exo-deploy --zip /tmp/exo-20260101.zip all Everything after the source lands in the release directory is unchanged and shared by both modes — npm ci, the build, the %VITE_SITE_URL% check, prune, gzip, the atomic swap, the health check with rollback, the KEEP=3 rotation. The archive therefore carries sources, never a build: VITE_SITE_URL keeps coming from /etc/exo/<app>.build.env rather than from whoever packed the zip. Any .env that travelled inside the archive is dropped before the build. The archive is unpacked once, node_modules/dist/__MACOSX/.DS_Store skipped, and every target app is validated up front — a missing directory or a missing package-lock.json is reported for all four at once, before anything on disk moves. The landing directory is located by its package.json, so both fitnes/... and bundle/fitnes/... (what Finder's Compress produces) work. Also new, and shared by both modes: `all` deploys the four in apps.conf order, stopping at the first failure, and each release records where it came from in .deploy-source, so a live release can still be traced once the deploy output is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 | 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
apt update && apt full-upgrade -y
apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip unzip
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 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:
[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.
- Per domain at reg.ru DNS:
A @ → <VPS IPv4>,A www → <VPS IPv4>(+AAAAif available). - Confirm:
dig +short <domain> @77.88.8.8. - Comment out the two
443blocks in each rendered vhost (they reference certs that don't exist yet), thennginx -t && systemctl reload nginx. - 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 - Uncomment the
443blocks,nginx -t && systemctl reload nginx. - 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 # one app
sudo -u exo exo-deploy all # all four, in apps.conf order
Deploying from a zip archive
exo-deploy takes its code from one of two places: the bare repo (above) or a zip
archive sitting on the server. The archive path exists for code that is not in git on
this box — a handover build, a contractor's snapshot, a hotfix from a laptop.
exo-deploy <app|all> [git-ref] # from the bare repo, default ref main
exo-deploy --zip <archive.zip> <app|all> # from an archive
The archive holds sources, not a build. Everything after the source lands in the
release directory is identical in both modes — npm ci, npm run build, the
%VITE_SITE_URL% check, npm prune, gzip, the atomic current swap, the health check
with automatic rollback. That is the point: VITE_SITE_URL comes from
/etc/exo/<app>.build.env on this server and never from whoever packed the archive.
Expected layout — the landing directories as they sit in the repo, either at the root of the archive or under one wrapping directory (what Finder's "Compress" produces):
fitnes/ hotel/ medcenterphysio/ medcenterstart/ # or bundle/fitnes/ ...
package.json, package-lock.json, index.html,
src/, server/, shared/, scripts/, public/, tsconfig*.json, vite.config.ts
package-lock.json is required — npm ci refuses to run without it, and exo-deploy
says so up front for every app in the archive rather than failing halfway through.
node_modules/, dist/, __MACOSX/ and .DS_Store are skipped during unpacking, and
any .env / .env.local that travelled inside the archive is deleted before the build.
Pack it on the dev machine, from the repo root:
zip -r exo-$(date +%Y%m%d).zip fitnes hotel medcenterphysio medcenterstart \
-x '*/node_modules/*' '*/dist/*' '*/.env' '*/.env.local' '*/.DS_Store' '*/legacy/*'
Then ship and deploy. /tmp works; the file only has to be readable by exo:
scp exo-20260101.zip <server>:/tmp/
sudo -u exo exo-deploy --zip /tmp/exo-20260101.zip all
all runs the four in apps.conf order and stops at the first failure — landings
already swapped in stay on their new release, the one that failed rolls itself back.
Rerun for the rest once the cause is fixed.
Each release records where it came from, so a live one can be traced long after the deploy output has scrolled away:
cat /srv/exo/<app>/current/.deploy-source
# zip exo-20260101.zip sha256=9f86d0...
# deployed 2026-01-01T09:12:44Z
Runbook
| Task | Command |
|---|---|
| Deploy | sudo -u exo exo-deploy <app|all> [ref] |
| Deploy from a zip | sudo -u exo exo-deploy --zip <archive.zip> <app|all> |
| What is live | cat /srv/exo/<app>/current/.deploy-source |
| 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/*.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://<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.tsis an in-memory fixed-window limiter, single-process by design. Horizontal scaling needs a shared store first. Not a concern at landing-page traffic; nginxlimit_reqis 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. Until then--zipis the way to deploy code that never reached this server's git remote. - One Metrika counter for four domains. All four landings carry the same
Yandex.Metrika counter (
112352796) inindex.html, so reports mix the domains and every domain has to be listed in the counter's settings, or its hits get filtered. - The
FORM_SUCCESSgoal has to exist in the Metrika UI.src/lib/lead.tsfiresym(112352796, 'reachGoal', 'FORM_SUCCESS')on every successful form submission, but the goal itself is not part of the code: create it in the counter's settings as «JavaScript-событие» with the identifierFORM_SUCCESS, or the hits arrive and no report ever shows them. The honeypot answer (leadId: 0) deliberately does not count. - Still open before launch (not a deployment blocker): the forms show implicit 152-ФЗ consent text with no link to a published privacy policy.