docs deploy
Deploying the site and running the server
978 woorden · docs/deploy.md
The site (site/) is static: Vite builds it, nginx serves it, and nothing on the server runs Node. This
page is the reference for that setup — what the host holds, how a release lands, and how to roll one back.
Every step described here is idempotent, so re-running any of it on a fresh host reproduces the same state.
The current host was provisioned on 2026-09-04 and last rebooted the same evening (kernel 7.0.0-30);
everything came back on its own — nginx, fail2ban, docker — in about 30 s. The webtop desktop container
that came with the box was removed on Berkay's word that evening (its data stays in /opt/webtop/config and
/srv/workspace, the compose file in /opt/webtop); port 3001 is closed again.
The server
root@64.177.43.110 — Ubuntu 26.04, 8 CPU / 30 GB / 600 GB. Key-based SSH; the key that deploys
from GitHub can do nothing else (see below).
Installed: nginx 1.28 (site config in /etc/nginx/sites-available/rovecode, the default server
on :80), fail2ban (sshd jail, 1 h ban after 5 failures in 10 min), ufw (22, 80, 443 — nothing else), unattended-upgrades,
certbot + python3-certbot-nginx (unused until there is a domain), bun and node for builds.
Time zone Europe/Istanbul.
Web root layout — atomic releases:
/var/www/rovecode/
current → releases/20260904-191403 (symlink; nginx root)
releases/20260904-173814/ … 20260904-191403/ (five kept)
nginx serves current with try_files $uri $uri/ $uri.html =404, a real 404.html, gzip,
30-day immutable caching on hashed assets, no-cache on html, server_tokens off.
Ubuntu's nginx.conf already sets gzip on and server_tokens — a conf.d file that repeats
them fails nginx -t with "directive is duplicate" (this cost the first bootstrap run).
Deploying
bun run deploy:site # build:all (docs + market) → tar over ssh → new release → flip current → curl 200
scripts/deploy-site.sh --no-docs # landing page only (bun run build), no /docs/ or /market/
scripts/deploy-site.sh --no-build # ship the existing site/dist
scripts/deploy-site.sh --check # after the flip, walk every URL in the live sitemap
scripts/deploy-site.sh --rollback # the previous release becomes current
DEPLOY_HOST=root@1.2.3.4 scripts/deploy-site.sh # another host
The default build target is build:all (VITE_DOCS=1 VITE_MARKET=1) — /docs/ and /market/, plus
the markdown mirror, 735 pages in all. The flip is the last step, so a half-uploaded release is never
served. Verification is a GET with a 200 check; --check then runs site/scripts/live-check.mjs over
every URL in the live sitemap (status, lang, canonical, hreflang, console, axe) and rolls back only
after two failed attempts, because a headless Chromium that crashes on one page once is not evidence
that the release is bad — it rolled a good release back before that second attempt existed.
The manual deploy and the CI deploy do not ship the same thing. deploy-site.sh defaults to
build:all; .github/workflows/site.yml still runs bun run build:docs, so a CI-driven deploy would
publish the site without /market/. Whichever runs last wins. Nothing has drifted in practice only
because Actions has not run at all on this repository (billing, see below) — that is luck, not design,
and the workflow needs the same target before Actions is turned back on.
A full sweep of 735 pages is memory-hungry: it was killed by the OOM killer on a loaded workstation
mid-run, after the upload and the flip had already succeeded. --shard i/n and --only <substring>
exist for that — a shard is a sample, not equivalent to the sweep, and the difference is worth stating
when reporting a result.
What nginx serves, and three things it was doing wrong
Measured 2026-09-05 on the live host and fixed there:
- The markdown mirror was served without a charset.
Content-Type: text/markdownwith nocharset=utf-8: HTTP does not defaulttext/*to UTF-8, and unlike an HTML page a.mdfile has no<meta charset>to fall back on, so every em dash and middle dot arrived as mojibake for a client that believed the header. That surface exists to be read by other agents, which is exactly the audience least likely to guess. Fixed withcharset utf-8; charset_types text/markdown …. .mdwas not ingzip_types. 404 KB of mirror served uncompressed; the index alone went 10,465 → 4,397 bytes once added, and it is the same prose that compresses 83% as HTML.font/woff2WAS ingzip_types. woff2 is already compressed; gzipping it measurably produced a larger response (24,836 → 24,864 bytes) and spent CPU per request to do it. Removed.
Also: hashed assets carried both an expires 30d and an add_header Cache-Control … immutable, so
every asset answered with two Cache-Control headers, and the shorter one contradicted the config's
own comment ("can be cached for a year"). The filenames are content-hashed, so it is now a single
one-year immutable header.
GitHub Actions
.github/workflows/site.yml builds and deploys on every push to main that touches site/;
.github/workflows/ci.yml runs tsc + bun test on Ubuntu for everything else. The suite is
Linux-clean — tests must not assume C:/ paths, backslash separators or readdir order.
The deploy key is bound on the server (~/.ssh/authorized_keys) to a forced command,
/usr/local/bin/rovecode-deploy-receive, with no-pty and no forwarding: it reads a tar.gz of
the build on stdin, unpacks it as a new release, refuses without an index.html, flips
current, keeps five. Secrets: DEPLOY_SSH_KEY (private key), DEPLOY_KNOWN_HOSTS (the host
key line, StrictHostKeyChecking=yes), DEPLOY_HOST. Optional repository variable SITE_URL
for absolute social tags once a domain exists (the build falls back to the IP).
If the Actions jobs show "not started because recent account payments have failed", that is the organisation's GitHub billing, not the workflow — deploy by hand with the script meanwhile.
Open
- No domain → HTTP only. When one points at the IP:
certbot --nginx -d <domain>, then setSITE_URLand rebuild so canonical / og:url carry the domain. - Root password login is still enabled; with the key in place
PermitRootLogin prohibit-passwordin/etc/ssh/sshd_configcloses it.