Add deployment tooling for a single production VPS

The repo had no infrastructure at all: no Dockerfile, no nginx config, no
systemd unit, no deploy script, no CI. Deployment guidance existed only
as two prose lines in each README.

Target shape is one reg.ru KVM box serving all four landings, each on its
own domain root. nginx serves the built SPA off disk and proxies only
/api/* to a small Express process per app, so a crashed or restarting API
takes down the form rather than the page. Releases are built on the
server into a timestamped directory and swapped in by renaming the
`current` symlink.

  deploy/apps.conf      domains and ports, the one place to edit
  deploy/README.md      hardware/software spec, provisioning, runbook
  deploy/bin/           exo-deploy, exo-render-nginx
  deploy/nginx/         vhost template, shared snippets, http extras
  deploy/systemd/       one template unit for all four instances
  deploy/env/           annotated templates for the two env files

exo-deploy asserts the VITE_SITE_URL substitution actually happened,
prunes devDependencies after building, precompresses for gzip_static,
health-checks after the swap and rolls back on its own if the new release
fails to answer. exo-render-nginx refuses to emit a vhost while a
placeholder domain is still in apps.conf.

Secrets are split across two env files: the amoCRM token stays in
/etc/exo at mode 640 and is injected by systemd, while only public
build-time vars are copied into a release. dotenv does not override
variables already in the environment, so the two coexist safely.

Chose systemd over Docker deliberately — no database, no conflicting
runtimes, no CI or registry to build images in, and containerising fights
the serve-static-from-disk design. Reasoning and the triggers to revisit
it are recorded in the README.

Domains are still placeholders; nothing here has been run against a
server yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 15:57:49 +06:00
co-authored by Claude Opus 5
parent b673367c6f
commit d99b9cb0bb
12 changed files with 564 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
.DS_Store
deploy/rendered/
+237
View File
@@ -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 | 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
```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 <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.
```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 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`:
```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 @ → <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:
```bash
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:
```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 <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/*.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.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.
+8
View File
@@ -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
+95
View File
@@ -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 <fitnes|hotel|medcenter|medcenterpersonal> [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"
+22
View File
@@ -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"
+11
View File
@@ -0,0 +1,11 @@
# Install as /etc/exo/<app>.build.env — chmod 644
# Public, build-time only. exo-deploy copies this to <release>/.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=/
+26
View File
@@ -0,0 +1,26 @@
# Install as /etc/exo/<app>.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
+19
View File
@@ -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;
+83
View File
@@ -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; }
}
+15
View File
@@ -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;
+11
View File
@@ -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;
+36
View File
@@ -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