Capping a site build on a 1.7 GB server
This site builds itself on a one-CPU server with 1.7 GB of RAM. The uncapped build peaked at 677–689 MiB; a systemd cap at 450 MiB cost nothing measurable. What the two memory numbers mean, and what did not work.
This site is deployed by a push. A Gitea webhook writes a trigger file, a systemd path unit picks it up, and a oneshot
service runs npm ci, astro build into a fresh releases/<sha>-<time>/ directory and swaps the dist symlink with one
rename. A failed build leaves the live release alone. The server is the same one that runs the mail, the wiki and two
products: one CPU, 1707 MiB of RAM, and about 1 GB of it in swap on a normal day.
Two numbers, not one
The deploy script logs two memory figures per run. The lower bound is the max RSS of the
largest process, astro build, from /usr/bin/time. The upper bound is the whole job’s cgroup memory.peak, which
counts every process in the unit and the page cache the build touches. On the two uncapped runs on 2026-10-13 the
astro process peaked at 296.6 and 284.3 MiB; the cgroup at 676.8 and 688.8 MiB. The gap is npm ci and cache, and the
cgroup figure is the one the kernel will act on.
The cap
The unit now carries MemoryHigh=450M, MemoryMax=700M, Nice=10 and best-effort IO at priority 7.
Over MemoryHigh the kernel reclaims and throttles the job; over MemoryMax it kills it, and the deploy script then
removes the half-built release and leaves dist pointing where it was. 450 MiB is below what the uncapped build used,
on purpose: most of that peak was page cache, which is the first thing reclaim takes.
What it cost: nothing measurable
Every capped deploy has finished — twelve of twelve by 13:43 on 2026-10-13. Each run’s cgroup peak sits at
472.1–472.3 MB, which is the 450 MiB line, as expected. The astro process itself peaked at 260–272 MiB per run, and the
build took 20.1–29.9 seconds against 22.7 and 25.8 seconds uncapped. The whole deploy, trigger to symlink, is about a
minute. On a one-CPU box the Nice=10 matters more than the memory line for anyone using the mail server during a push.
Two things that did not work
MemoryDenyWriteExecute=yes, the usual hardening line, crashes Node: its JIT needs
pages that are writable and then executable. The unit says so in a comment, so the next person does not add it back.
And systemd’s own MemoryPeak= on a oneshot service is not a per-run figure: it reported the same 716.8 M on every
uncapped run and reads as unset afterwards. The script reads memory.peak from the cgroup itself and writes it to the
deploy log, where it can be compared run by run.
Left open
npm ci runs on every deploy and is the heaviest phase; a colleague’s reading of top during two
pushes put it at about 425 MB resident. Skipping it when the lockfile is unchanged would remove that phase from most
deploys. Not done yet; it is a five-line change and a decision.