Skip to main content

How beru.io.vn runs: Cloudflare, nginx, PM2 and a 502 every 30 seconds

beru.io.vn is one self-hosted Next.js process behind nginx, PM2 and Cloudflare, deployed by a shell script on the box itself. Measuring it on 23 Oct 2026 showed PM2 killing the app on every 30-second memory check because Next.js idles above the 256M max_memory_restart ceiling, and nginx serving 502s in the gap. This is the stack, the deploy steps, the agent-facing endpoints, the fix and what is still wrong.

Tested on
next-server (v15.5.19) with PM2 6.0.14, 23 Oct 2026
Error
Process 3 restarted because it exceeds --max-memory-restart value
Changed
max_memory_restart: "256M"→max_memory_restart: "768M"
"next": "15.5.19"→15.5.28
Fixed in
max_memory_restart 805306368
On this page

Everything on this site runs on a VPS I manage myself: Next.js behind nginx and PM2, with Cloudflare in front of it. This post is how beru.io.vn runs, layer by layer, written from commands I ran against the live server on 23 Oct 2026, and it includes the bug those commands turned up: PM2 had been killing the site every 30 seconds for weeks, and nginx was serving 502s in the gap.

Nothing here is a reference architecture. It is one shared box, one Node process and one shell script, and the point of writing it down is that every number below came from the machine, not from memory. Where I could not verify something, the post says so.

The stack: Cloudflare, nginx, PM2, Next.js standalone, Postgres

A request to beru.io.vn passes through five layers:

browser
  → Cloudflare (DNS, TLS, static asset cache)
  → nginx 1.31.7 (reverse proxy on :80 / :443)
  → PM2 6.0.14 (one fork-mode process)
  → Next.js standalone server.js on port 4237 (Node v22.23.4)
  → Postgres through Prisma

The host is a single VPS running Ubuntu 24.04.3 LTS with 8 vCPU, about 16 GB of RAM and a 120 GB disk that is 61% full. When I collected these numbers the kernel had been up for 255 days. The box is shared: PM2 runs six apps on it, and nginx serves a few other subdomains of beru.io.vn. That matters later, because memory on this machine is not mine alone.

Cloudflare

Cloudflare sits in front for DNS and TLS, and it advertises HTTP/3 (alt-svc: h3=":443"; ma=86400). It does far less caching than people tend to assume. On the free tier HTML is not cached by default, and the headers confirm that every page goes straight to the origin:

HTTP/2 200
content-type: text/html; charset=utf-8
server: cloudflare
x-nextjs-cache: STALE
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
cache-control: s-maxage=60, stale-while-revalidate=31535940
cf-cache-status: DYNAMIC

cf-cache-status: DYNAMIC means Cloudflare passed the request through. Static chunks are a different story. A request for a file under /_next/static/chunks/ came back with cf-cache-status: HIT and age: 4848210, which is about 56 days at the edge. So Cloudflare holds the JavaScript and CSS, and every HTML request reaches nginx and then Next.js. Even llms.txt is DYNAMIC.

nginx

nginx is the aaPanel build under /www/server, version 1.31.7. The vhost for beru.io.vn is a plain reverse proxy with no proxy_cache. The parts that matter:

nginx
location /_next/static/ {
    proxy_pass http://<redacted-ip>:4237;
    expires 365d;
    add_header Cache-Control "public, immutable";
}
location / {
    proxy_pass http://<redacted-ip>:4237;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_cache_bypass $http_upgrade;
}

The upstream is the loopback interface on port 4237. Static chunks get a one-year immutable header, and a location block returns 404 for dotfiles such as .env, .git and .user.ini. There is exactly one upstream, which comes up again in the 502 section.

PM2 and the Next.js standalone server

Next.js builds with output: "standalone", which produces a self-contained server.js plus the subset of node_modules it needs. On the server the whole .next/standalone directory is 161 MB. PM2 runs it from the repository's ecosystem.config.cjs:

js
name: "portfolio",
script: ".next/standalone/server.js",
instances: 1,
exec_mode: "fork",
env: { NODE_ENV: "production", PORT: 4237, HOSTNAME: "0.0.0.0" },
max_memory_restart: "256M",
log_date_format: "YYYY-MM-DD HH:mm:ss",

That is one process in fork mode, not a cluster. The last line, max_memory_restart: "256M", is the subject of most of this post. It had been in the file since the first PM2 deploy commit on 21 Mar 2026.

Public pages that read from the database use incremental static regeneration with revalidate set to 60 seconds, a value lowered on 21 Jun 2026. Combined with Cloudflare passing HTML through, that means Next.js serves a cached render and regenerates it in the background at most once a minute per page. That is what the x-nextjs-cache: STALE header above is showing.

Postgres

Content (posts, drafts, projects) lives in Postgres behind Prisma; prisma/schema.prisma declares provider = "postgresql". The box runs two Postgres servers: a PostgreSQL 18.0 installed through aaPanel and listening on port 5432, and a postgres:16-alpine Docker container. Which one this site uses is set in DATABASE_URL in the server's .env, and I did not open .env to write a blog post. So this post does not name the Postgres version this site runs on.

The deploy script, step by step

There is no CI and no build server. Deploying means running deploy.sh on the production box itself, in the app directory. The script starts with set -euo pipefail, and an ERR trap posts "Deploy Failed" with the name of the failing step to a Discord webhook (<redacted-webhook>). Every step also posts a start notice, so a deploy reads as a short timeline in a Discord channel.

0. Preflight. The script refuses to run over hand edits:

bash
if ! git diff --quiet || ! git diff --cached --quiet; then
  echo "Tracked changes detected; deployment aborted."
  git status --short
  exit 1
fi

Untracked host files that aaPanel creates, such as .user.ini and .well-known, are allowed through; those were moved into .gitignore on 24 Aug 2026. The preflight also requires corepack and an exact pnpm version, 10.26.1, pinned in the deploy script on the same day.

1. Pull. git pull --ff-only origin main. If a GitHub token is configured on the server, the script injects it into the remote URL first. A fast-forward-only pull means a diverged server checkout fails loudly instead of merging.

2. Install. corepack pnpm install --frozen-lockfile, so the lockfile in git is the lockfile in production.

3. Database. prisma generate, then prisma migrate deploy. Migrations run before the build, against the live database.

4. Build. rm -rf .next, then pnpm build. The 8-vCPU production box compiles its own site.

5. Static files. The standalone output does not include .next/static or public, so the script copies them in:

bash
mkdir -p .next/standalone/.next/static
cp -rf .next/static/. .next/standalone/.next/static/
rm -rf .next/standalone/public
cp -r public .next/standalone/public

The comment on this step says "merge, keep old chunks for active users". It dates from 22 Mar 2026, when a deploy left open tabs requesting JavaScript chunks that no longer existed and the fix was to copy new chunks on top of old ones.

6. Restart. pm2 restart ecosystem.config.cjs --update-env || pm2 start ecosystem.config.cjs, then a "Deploy Complete" message with the commit range and the duration.

For the deploy on 20 Oct 2026, file timestamps on the server put the build, copy and restart at about 1 minute 46 seconds: the .next directory was created at 19:58:10 server time and PM2 logged the app online at 19:59:56. The pull, install and migrate steps happen before that, and their timing is only in the Discord channel, which I did not read for this post.

What the site exposes to AI agents

This site is open to AI crawlers on purpose, and a good share of its surface exists for agents rather than browsers. All of it is served by the same Next.js process described above.

robots.txt. The default group allows everything except /admin/ and /api/, with an explicit Allow: /api/openapi.json and Allow: /.well-known/. A second group repeats those rules by name for GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-User, PerplexityBot, Google-Extended, Applebot-Extended, meta-externalagent and Bingbot.

llms.txt and llms-full.txt. /llms.txt lists the about page, services, projects and posts, and its header tells agents: "Every URL below has a markdown version: prefix its path with /md". /llms-full.txt is the whole site in one request.

The markdown mirror. /md/about returns text/markdown; charset=utf-8 with X-Robots-Tag: noindex, so agents can read it and search engines keep ranking the HTML. The site also rewrites requests that send Accept: text/markdown, but a comment in the route explains why the /md prefix exists anyway: on production that rewrite "intermittently answers HTML" for prerendered routes, and the /md URLs do not go through middleware at all.

API catalog and Link headers. /.well-known/api-catalog answers with content-type: application/linkset+json:

json
{"linkset":[{"anchor":"https://beru.io.vn/api","service-desc":[{"href":"https://beru.io.vn/api/openapi.json","type":"application/openapi+json"}],"service-doc":[{"href":"https://beru.io.vn/api-docs","type":"text/html"}],"status":[{"href":"https://beru.io.vn/api/health","type":"application/json"}]}]}

Every HTML response also carries a link: header pointing at /api/openapi.json and /api-docs.

An OAuth-protected MCP server. /api/mcp is a Model Context Protocol server that AI writing agents use to file drafts on this blog. An unauthenticated request gets a 401 that tells the client where to start:

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://beru.io.vn/.well-known/oauth-protected-resource/api/mcp", scope="mcp:write"

The metadata document at that URL names the authorization server and the one scope:

json
{"resource":"https://beru.io.vn/api/mcp","authorization_servers":["https://beru.io.vn/api/auth"],"bearer_methods_supported":["header"],"dpop_signing_alg_values_supported":["EdDSA","ES256","ES512","PS256","RS256"],"scopes_supported":["mcp:write"]}

The server registers two tools, list_posts and create_draft, and nothing else. There is no publish, update or delete tool, and create_draft forces published to false rather than taking it as an argument, so no prompt can talk a draft past human review. I wrote about how these agent-facing channels behave under real requests in llms.txt v2 Shipped. Here's What My Site Still Gets Wrong About Agent-Readability.

The 502s on /vi and /about

The symptom was small. Every so often a request for /vi or /about returned a 502, and the next request returned 200. The health endpoint answered {"status":"pass"} in 0.24 seconds when I checked it. Most visitors got a stale ISR page and never saw anything wrong, which is exactly why it went unnoticed.

Finding it

The nginx error log for 23 Oct 2026 had 114 upstream errors, and every one of them was the same:

2026/10/23 17:48:31 [error] 2164752#0: *21800 connect() failed (111: Connection refused) while connecting to upstream, client: <redacted-ip>, server: beru.io.vn, request: "GET /vi HTTP/2.0", upstream: "http://<redacted-ip>:4237/vi", host: "beru.io.vn"
2026/10/23 17:48:32 [error] 2164752#0: *21803 connect() failed (111: Connection refused) ... request: "GET /about HTTP/2.0" ...

"Connection refused" on a loopback upstream means nothing was listening on port 4237 at that moment. Not slow, not timing out: absent. The access log showed the matching 502:

<redacted-ip> - - [23/Oct/2026:17:48:31 +0700] "GET /vi HTTP/2.0" 502 150 "-" "curl/8.7.1"

The PM2 log, at the same second, shows why:

2026-10-23T17:48:31: PM2 log: [PM2][WORKER] Process 3 restarted because it exceeds --max-memory-restart value (current_memory=340930560 max_memory_limit=268435456 [octets])
2026-10-23T17:48:31: PM2 log: App [portfolio:3] online

The timing gave it away. The seconds field of every 502 that day, taken modulo 30, was 1, 2 or 27: always right around :01 and :31. PM2's memory worker runs every 30 seconds (the daemon banner prints Worker Interval : 30000). Something was firing on every worker tick.

Before settling on PM2's memory check I ruled out the other things that restart a Node process on a schedule. The crontab held only aaPanel's own jobs, none of them touching this app. PM2's watch was off and cron_restart was not set.

The numbers

The same line repeats all through ~/.pm2/pm2.log. That log begins on 3 Oct 2026, and the first occurrence is 30 seconds after it starts:

2026-10-03T16:08:03: PM2 log: [PM2][WORKER] Process 22 restarted because it exceeds --max-memory-restart value (current_memory=269914112 max_memory_limit=268435456 [octets])

grep -c "exceeds --max-memory-restart" counted 26,359 of them, all for this app. Per day:

Dates (Oct 2026)Memory restarts per day
9 to 14497 to 690
151,392
16 to 222,349 to 2,612
23 (until 17:57)1,765

From 16 Oct onward that is roughly one kill per worker tick, around the clock. The jump lines up with a run of deploys on 15 and 16 Oct, the WebGL home page among them. I did not record memory per deploy, so that is a correlation, not a measured cause.

The 502s in the access log per day: 16 Oct 40, 17 Oct 5, 18 Oct 14, 19 Oct 15, 20 Oct 30, 21 Oct 18, 22 Oct 17, and 23 Oct 54 out of 2,846 requests, about 1.9%.

The restart counter in pm2 describe portfolio read 679, which looks bad but undercounts. The PM2 daemon itself restarted at 10:34:30 that morning and resurrected the app from its dump file, which renumbered it from id 22 to id 3 and reset the counter. 679 covers about the last seven and a half hours.

There was a worse failure mode hiding behind the 502s. Five times in October PM2 gave up entirely:

2026-10-15T16:09:21: PM2 log: Script /www/wwwroot/beru.io.vn/.next/standalone/server.js had too many unstable restarts (16). Stopped. "errored"

That happened twice on 6 Oct, and once each on 15, 16 and 20 Oct. In that state PM2 stops restarting the app, and the site stays down until something starts it again, such as a deploy or a daemon resurrect.

The cause

max_memory_limit=268435456 is 256 MiB, the "256M" from ecosystem.config.cjs. The Next.js standalone server uses more than that before it has served a single request. I watched a fresh process after a restart:

2026-10-23 17:58:02:  ✓ Ready in 323ms
PID 2402861  ELAPSED 8s  RSS 304960 KB  next-server (v15.5.19)   (12s: 331160, 28s: 334912)

About 305 MB at 8 seconds, 335 MB at 28 seconds, with no traffic yet. The memory worker samples on its 30-second tick, finds the process over 256 MiB, and restarts it. In fork mode a restart is a stop (SIGINT) followed by a start:

2026-10-23T17:57:31: PM2 log: [PM2][WORKER] Process 3 restarted because it exceeds --max-memory-restart value (current_memory=326651904 max_memory_limit=268435456 [octets])
2026-10-23T17:57:31: PM2 log: Process 3 in a stopped status, starting it
2026-10-23T17:57:31: PM2 log: Stopping app:portfolio id:3
2026-10-23T17:57:31: PM2 log: App [portfolio:3] exited with code [0] via signal [SIGINT]

For about a second between the kill and "Ready", nothing listens on 4237, and any request that reaches nginx in that window gets "Connection refused" and a 502. Twice a minute, every minute.

This was not a memory leak. A leak grows over hours; this process was over the limit at birth. The ceiling was set on 21 Mar 2026 and never revisited while the app grew underneath it.

Fixing "Process 3 restarted because it exceeds --max-memory-restart value"

The fix is commit 91551e1, one line in ecosystem.config.cjs:

diff
-      max_memory_restart: "256M",
+      // Next standalone idles at ~330 MB RSS; a lower ceiling restarts it on every 30 s PM2 check (502s).
+      max_memory_restart: "768M",

768M is about 2.3 times the 300 to 390 MB I measured. The machine had 6.7 GB available when I checked, so the headroom is there, and the ceiling goes back to being what it was meant to be: a safety net for a real leak, not a timer.

I considered two other changes and left both out. Adding proxy_next_upstream to nginx would not help, because there is only one upstream to retry against. Capping the V8 heap with node_args: "--max-old-space-size=384" would make the garbage collector work earlier; it is not part of this commit, and the 768M ceiling still catches a process that keeps growing.

Getting the new value onto the running process needs care. deploy.sh restarts with pm2 restart ecosystem.config.cjs --update-env, and PM2 keeps an app's options in its dump file, so I don't take it on trust that a restart picks up a new max_memory_restart. The check after deploy is pm2 describe portfolio, which should show the 768M limit, plus a grep of ~/.pm2/pm2.log for new "restarted because it exceeds" lines. If the old limit is still there, the reliable route is pm2 delete portfolio && pm2 start ecosystem.config.cjs && pm2 save, at the cost of a second or two of downtime.

After the fix

I deployed the fix at about 19:10 server time on 23 Oct 2026. deploy.sh's plain pm2 restart ecosystem.config.cjs --update-env was enough: right after it, PM2 reported the new ceiling in bytes.

max_memory_restart 805306368

I ran pm2 save once by hand so a reboot resurrects the app with the same limit. In the first ten minutes after that deploy there were no new "restarted because it exceeds" lines in pm2.log, and none of the 303 requests nginx logged returned 502. The restart counter, which had climbed by one every 30 seconds all day, stopped moving.

One number surprised me. On Next.js 15.5.28 (the upgrade below, shipped in the same deploy), the process settled at 485 to 496 MB of RSS over five minutes of sampling, more than the 300 to 390 MB I measured on 15.5.19. That still leaves about 270 MB under the 768M ceiling, but it is the reason I check the restart counter again after every deploy rather than calling this done.

Patching Next.js: 15.5.19 to 15.5.28

The next-server (v15.5.19) in the memory line above is the version production ran on 23 Oct 2026. It had been pinned at 15.5.19 since 21 Jun 2026. That version sits inside the affected ranges of two critical unauthenticated remote code execution advisories in the Image Optimization API:

The AVIF one applies to this site directly. next.config.ts has had formats: ["image/avif", "image/webp"] under images since 22 Mar 2026, so AVIF optimization has been on in production all along.

An earlier post on this blog walked through the same AVIF advisory and showed [email protected] installed, with no AVIF in images.formats. The repository says otherwise: package.json held "next": "15.5.19" from 21 Jun 2026 until the upgrade below, and AVIF was on the whole time. The record here is what git shows.

The upgrade is commit fc5ed97, which moves next from 15.5.19 to 15.5.28 in package.json and pnpm-lock.yaml. Per its message, 15.5.28 also closes the Server Actions denial-of-service advisory and the cache-poisoning advisory for self-hosted SSG/ISR. It stays on the 15.5 line, so no major-version migration rides along with a security patch. Both commits travel through the same deploy.sh as everything else. When I collected these numbers, production was still running 999c234 from the 20 Oct deploy, so neither had reached the server yet.

What is still not right

These are the problems I measured on 23 Oct 2026 and have not fixed. They stay on this list until a later check shows them gone.

HSTS is sent twice, with different values. Both Next.js and nginx add the header, so every page response carries two:

strict-transport-security: max-age=63072000; includeSubDomains
strict-transport-security: max-age=31536000

The first comes from next.config.ts (line 76), the second from an add_header in the nginx vhost. They disagree on both max-age and includeSubDomains, so what a browser stores depends on which line it honours. One of them should go, and the open question is which layer owns security headers.

Swap is full. free -m showed Swap: 2047 2047 0: all 2 GB used, while about 6.7 GB of RAM was still available. The box runs six PM2 apps and other sites, and pages sitting in swap are memory something will wait on when it touches them again. I have not traced which process holds them.

The "keep old chunks" step keeps nothing. Step 5 of the deploy script exists to keep old JavaScript chunks around for tabs opened before a deploy. Since 11 Aug 2026, though, step 4 starts with rm -rf .next, and .next/standalone/.next/static lives inside .next. By the time the copy runs, there are no old chunks to merge with. Two things soften this: Cloudflare keeps serving cached chunks from its edge, and src/app/error.tsx reloads the page when it catches a ChunkLoadError. I have not measured how often anyone hits that reload.

One shared box. The production server builds its own code, runs migrations against its own database and shares memory with five other apps. A heavy build on one app is felt by the others. There is no staging copy to try a change on first.

The API catalog does not list the MCP server. /.well-known/api-catalog points at the REST API and its OpenAPI document, but not at /api/mcp. Agents find the MCP server through its own 401 and metadata document, not through the catalog.

What I check now

The 502s lasted weeks because every layer looked healthy on its own. The health endpoint said pass, Cloudflare served the static files, and ISR covered most pages with a stale copy. The signal was in the logs the whole time, sorted by second:

bash
grep -c "exceeds --max-memory-restart" ~/.pm2/pm2.log
pm2 describe portfolio

If the first number climbs between two runs, the process is being killed on a timer, not because it leaked. And max_memory_restart belongs next to the measured memory of a fresh process, re-measured whenever the app grows, not next to a number picked on the first day.

More posts on running this server are in the self-hosting topic.

Written and tested by Nguyen Hoang Loc (Beru), Ho Chi Minh City