Skip to content

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.

  • Linux
  • systemd
  • Self-hosted
  • Deploy

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.

← all notes

esc
↑↓ nav ↵ open esc close