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:
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user