8 Commits
Author SHA1 Message Date
Yuriy PanovandClaude Opus 5 da93bbc9f3 Remove the release directory a failed deploy leaves behind
A deploy that died after mkdir -- a broken build, a missing lock file, a
health check that never went green -- left its half-finished release in
releases/. That is not just disk: it is the newest directory there, so the
KEEP=3 rotation of the next successful deploy counted it among the three to
keep and dropped a working release instead, thinning out exactly the rollback
targets the runbook tells you to use.

The release directory is now armed for cleanup the moment it is created and
disarmed once the release is live and healthy, with the removal done by the
same EXIT trap that drops the unpacked archive -- so it covers every way out:
fatal, a command failing under set -e, an aborted build. The one case left
alone is a release the swap did reach and that `current` still points at, i.e.
a first-ever deploy with nothing to roll back to: removing that would leave a
dangling symlink behind instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:33:54 +03:00
Yuriy PanovandClaude Opus 5 5dd6bbc217 Add a zip archive as a second source for exo-deploy
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>
2026-09-17 22:27:27 +03:00
Yuriy PanovandClaude Opus 5 8af2e463b3 analytics: count a successful form as the FORM_SUCCESS Metrika goal
The counter was in place but nothing reached it: src/lib/lead.ts only pushed
a custom dataLayer event, which Metrika's ecommerce: 'dataLayer' ignores, so
form conversions were not counted at all.

All five forms — including the fitnes callback modal — funnel through the one
success branch in submitLead(), so a single ym(112352796, 'reachGoal',
'FORM_SUCCESS') per landing covers every one of them. The counter id is
hardcoded to match the inline snippet in index.html; two sources for it would
drift. The call is optional-chained and wrapped in try/catch, because losing
analytics must never cost a lead.

The honeypot is the one success that is not a conversion: the server answers
bots with 200 {ok:true, leadId:0} so they learn nothing, and leadId === 0 is
what keeps that answer out of the goal.

The goal itself still has to be created in the counter's settings as a
JavaScript event named FORM_SUCCESS — noted in deploy/README.md, replacing
the entry that said conversions were unwired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:30:47 +06:00
Yuriy PanovandClaude Opus 5 e7385e8898 analytics: add the Yandex.Metrika counter to all four landings
Counter 112352796 goes into the <head> of every landing's index.html, ahead
of the Vite entry, so the hit fires before React mounts. The pages are plain
SPAs served off disk by nginx (Express only answers /api/*), so the static
head is the only place it needs to be.

One counter serves all four domains, which means reports mix them and every
domain has to be listed in the counter's own settings or its hits get
filtered — noted in deploy/README.md along with the conversion tracking that
is still missing: src/lib/lead.ts pushes a plain custom dataLayer event,
which Metrika's ecommerce: 'dataLayer' does not read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:24:33 +06:00
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
Yuriy PanovandClaude Opus 5 51f39c9b88 deploy: follow the landing rename and the exorecovery.ru domains
Bring deploy/ in line with the renamed app directories and with the
domains that are actually configured in the per-app .env files:

  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

apps.conf, the README provisioning steps (directories, bare clones,
systemd units, the sudoers whitelist) and the exo-deploy usage line all
used the old medcenter / medcenterpersonal keys. The README table and
app.build.env.example still carried the *_DOMAIN placeholders.

Also refresh the tracked .env.example files: real VITE_SITE_URL values
instead of the old exodevices.ru sub-paths, and the amo lead tags that
match apps.conf.

Two lockfile fixes, both of which broke `npm ci` in exo-deploy:
- medcenterstart had no lockfile at all after package-lock.json and
  pnpm-lock.yaml were removed — restore package-lock.json and keep the
  pnpm one deleted, since the deploy path is npm;
- medcenterphysio/package-lock.json had lost "resolved" and "integrity"
  on 147 of its 236 entries — restore the complete file.

Verified: exo-render-nginx renders all four vhosts with no leftover
placeholders, and every package-lock.json agrees with its package.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:36:17 +06:00
yuriy.p be7741e269 domains 2026-08-29 16:07:27 +06:00
yuriy.pandClaude Opus 5 d99b9cb0bb 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>
2026-08-29 15:57:49 +06:00