# Application Hosting - everything RE:NODE has published

> Node.js, Python, Go, Java, Rust, C#/.NET, Bun and Deno applications, deployed straight from a Git repository.

Generated from https://www.renode.space/app-hosting, the live catalogue, the deploy docs, the panel guides and the RE:NODE blog. English. Source of truth for anything that changes: https://www.renode.space/app-hosting.

> This file is trimmed to stay under 400,000 characters: every post is listed with its link, but only the newest ones are included in full. Open a post's link for its text.

Contents: what it is, plans and prices, how it runs, panel how-to, tutorials, rules for the assistant.

## What it is

Deploy straight from a Git repository. The container pulls your branch on boot, installs what your lockfile asks for, and runs the start command you set. Push a commit and it can redeploy itself, with a record of every deploy and who triggered it.

### Git deploys, not FTP archaeology

Point a server at a GitHub repository and branch. Two independent switches decide the rest: pull on every start, and restart when GitHub reports a push. The panel keeps one row per deploy so you can see what shipped and when it actually came back up.

### Any framework, because none of them are special

Next.js, Nuxt, Express, Nest, Fastify, Adonis, Flask, Django, FastAPI, a Go service, Spring Boot, Quarkus, Micronaut, Ktor, ASP.NET Core, Blazor, Bun, Deno, a Discord bot, a scheduled scraper, a Cargo binary. They are all a process with a start command; we do not charge differently because yours has a logo.

### Databases beside the app

Every application plan carries database slots, created from the panel with credentials that are generated per database and never shared. phpMyAdmin single sign-on drops you straight in without a password ever reaching the browser.

### A domain in front of it

Each plan includes a reverse-proxy slot: point a hostname at your app and the panel handles the certificate and renewal. No separate proxy product, no separate bill.

### A working page before your code arrives

A new Python, Go, Java, Rust, C#, Bun, Deno server starts with a small hello-world page on its own address, so you can check that it answers before you deploy anything. Your code replaces it; it never overwrites files of yours.

### Questions about this service

**Can I run a Discord bot?**

Yes - a bot is the obvious case for these plans. Use the Node.js or Python line, point it at your repository, set the start command and add your token as an environment variable.

**Do you support private repositories?**

Yes. The panel installs a GitHub App, so it uses short-lived installation tokens rather than asking you to paste a personal access token that never expires.

**What happens when I push?**

If deploy-on-push is switched on, the panel receives GitHub's webhook, verifies its signature, refreshes the deploy token and restarts the container - and starts it if it was stopped, so the push goes live either way.

### General questions

### Getting started

**How quickly is my server ready?**

Game, app, web and database servers are created the moment payment clears, usually inside a minute. The game or runtime is installed and started for you, and the console is live in your browser while it happens. VDS and dedicated servers are prepared by hand and delivered within 24 hours.

**Do I need to configure anything before it works?**

No. The install runs, the configuration is written, and admin passwords are generated for your server rather than shared with everybody else's. The exception is four games whose publisher issues a credential to a person - those say so on the shop before you pay, and ask for it on a Setup tab afterwards.

**Which games need a key from me?**

Counter-Strike 2 needs a Steam game-server token, Don't Starve Together a Klei cluster token, FiveM a Cfx.re licence, and DayZ, Project Zomboid, 7 Days to Die, Arma 3 and Unturned need a Steam account or token of your own. We cannot create any of them and we never share one between customers. Your server is built immediately and the installation waits until you paste yours in.

**Can I try before buying?**

There is no free tier. The entry plans start at $1.50 a month precisely so that trying something costs about as much as a coffee.

**Can Claude or ChatGPT set my project up?**

Yes, and the panel gives it what it needs. On a server's Console tab, pick a purpose and press Connect AI: the panel copies a private link with the runtime, the port the code must read from SERVER_PORT, the code path, the GitHub branch, the databases and the limits. Paste it into Claude, ChatGPT or any assistant that can open links; the same link followed by /mcp works as an MCP server URL. It stops by itself after 7 days unused. You can also connect Claude, or any MCP client, to your whole account at https://panel.renode.space/api/client/mcp. Linked repositories get a pre-flight check that shows what will break before a deploy, no AI needed. No password or token is ever shown to the assistant - the AI setup page explains it all.

### Running the server

**Do I get full file access?**

Yes - a complete file manager with an in-browser editor, plus SFTP credentials. There is no plan on which the file manager is disabled or read-only.

**Can I install mods and plugins?**

Yes. Upload them through the file manager or SFTP, or use the game's own workshop support where it has any. Nothing here whitelists which mods you are allowed to run.

**Can other people help me run it?**

Yes. Invite subusers with granular permissions - console only, files only, no billing - or build a team with a role and grant it access to several servers at once. Permissions are a union, so nobody loses access they were given elsewhere.

**What happens if my server runs out of memory?**

New servers are configured so the kernel stops the container and it restarts clean, rather than swapping and dragging the whole machine down. If a server restarts repeatedly we warn you on the server page and open a support ticket before anything is suspended - you get told twice before it stops.

**Is the CPU limit a hard limit?**

Yes, and it cannot be breached: the container is throttled to the share you bought. A server sitting at 100% is slow, not broken, and is never suspended for it.

### Backups and safety

**Are backups included?**

Every plan carries backup slots - two on the entry tiers, more further up. Take them on demand or on a schedule, and restore with a button rather than a support ticket.

**Where are backups stored?**

Off the machine they protect. A backup sitting on the same disk as the thing it is insuring covers you deleting your world and covers nothing at all about the disk failing.

**What happens to my backups if I cancel?**

Deleting a server deletes its backups with it, deliberately and including locked ones. Download anything you want to keep first - the panel lets you.

### Billing

**How do I pay?**

PayPal, card, your wallet balance, or bank transfer. Every method produces a proper invoice you can download or print, with our details and the bank account on it while it is unpaid.

**Can I pay in Georgian lari?**

The panel displays every figure in lari at the National Bank of Georgia's published rate, marks converted figures as approximate, and always shows the amount that will actually be charged. Invoices stay in the currency they were raised in, because an invoice is a demand for a specific amount.

**What happens when my subscription is due?**

An invoice is raised and emailed to you seven days before expiry, and again on the day. Paying it settles that same document rather than raising a second one.

**Can I upgrade or downgrade later?**

Yes, and it does not rebuild anything. Changing plan changes the limits on the container you already have - your world, files and database stay exactly where they are.

**Do you offer refunds?**

There is no automatic refund button. If a server cannot do what it was sold to do, open a ticket and we will sort it out - including refunding you. We would rather fix the plan than argue about the policy.

### Support and the honest bits

**How do I get help?**

Open a ticket from the panel - it reaches every member of staff and every administrator, not a queue aimed at nobody in particular. Attach a screenshot if it helps; attachments are private to your ticket rather than sitting at a guessable public URL. Discord is there for faster, less formal questions.

**Where are the servers?**

Germany, on hardware we run. That is one location: if you need players in another region on a low ping, say so before buying rather than after.

**Is there an uptime guarantee?**

We measure node availability every five minutes and report it in the panel rather than printing a number with four decimal places we have not earned. If something is down you will usually hear it from us first.

**Who actually runs this?**

A small team that writes the panel, the shop and the automation itself. When something is wrong the fix is a code change, which is why the list of things wrong keeps getting shorter.

## Plans and prices

Read from the live catalogue at 2026-10-14 23:04 UTC. Prices are in US dollars per month; checkout also takes Georgian lari and bills for 1, 3, 6, 12 or 24 months. Prices change - re-check on https://www.renode.space/app-hosting or in the panel shop at https://panel.renode.space before quoting one.

### C# / .NET

Hosting for C# and .NET - ASP.NET Core, Blazor, Minimal APIs, Discord bots and worker services. Point it at a Git repository and a project.

Page: https://www.renode.space/app-hosting#csharp-dotnet

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Node JS

Node.js hosting for any framework — Next, Nuxt, React, Vue, Express, Nest, Fastify or Adonis. Push your code, set the start command, done.

Page: https://www.renode.space/app-hosting#nodejs

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Python

Python hosting for Flask, Django, FastAPI and bots. Bring a requirements file and a start command.

Page: https://www.renode.space/app-hosting#python

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Go

Run a Go service: your module is built with go build on every start and the binary runs on your own port. Deploy from GitHub or upload by SFTP.

Page: https://www.renode.space/app-hosting#go

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Java

Run any jar: Spring Boot, Quarkus, Micronaut or Ktor, on Java 17, 21 or 25. Spring Boot picks up your port by itself.

Page: https://www.renode.space/app-hosting#java

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Bun

Run JavaScript and TypeScript on Bun: fast installs, TypeScript without a build step, and package.json dependencies installed on every start.

Page: https://www.renode.space/app-hosting#bun

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Deno

Run JavaScript and TypeScript on Deno, installed into your server and pinned to the version you choose.

Page: https://www.renode.space/app-hosting#deno

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 4 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 5 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 6 | 1 |

### Rust (Cargo)

Hosting for Rust applications. Point it at a Git repository and it builds and runs with cargo on every start. Note: set the start-detection text on the Startup tab to a line your own program prints, or the panel shows the server as Starting for ever.

Page: https://www.renode.space/app-hosting#rust-cargo

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $4 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Advanced | $12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 2 | 1 |
| Pro | $17 | 6 GB | 2 vCPU | 35 GB | 2 | 2 | 1 |
| Premium | $23 | 8 GB | 3 vCPU | 50 GB | 2 | 2 | 1 |


## How it runs

### How a Renode app server runs

Source: https://www.renode.space/docs/overview

These pages describe how an application server on RE:NODE actually runs, so that you - or an AI assistant working for you - can deploy to one without guessing. They cover the app hosting lines (Node.js, Python, Go, Java, Rust, C#/.NET, Bun, Deno and Laravel / PHP), web hosting and database hosting. Every server is managed from the panel at [panel.renode.space](https://panel.renode.space).

If you only read one section, read **The five rules** below. Nearly every failed first deploy breaks one of them.

#### The five rules

1. **Listen on `0.0.0.0` and on the port in `SERVER_PORT`.** Exactly one port is reachable from outside: the one your server was given. Never hardcode a port and never bind `localhost` or `127.0.0.1`.
2. **Give the server something to start.** On Node.js that is a `start` script in `package.json`. On Python it is the entry file named on the Startup tab. A web hosting server starts nothing - it serves files.
3. **Build before you serve.** There is no separate build server. Either the start script builds, or the built output is committed.
4. **Keep secrets out of the repository.** Upload `.env` through the Files tab, or set variables on the Startup tab.
5. **Stay inside the memory limit.** It is a hard ceiling. A process that reaches it is killed with no error message - the console simply stops.

#### The container

Each server is one container. Your process runs as an unprivileged user, so there is no root, `sudo`, `apt`, `systemd` or Docker inside it. Install what you need through your language's own package manager (`npm`, `pip`), which works without root.

Your code lives in `/home/container`, and only `/home/container` persists. Anything your app writes at run time - uploads, a SQLite file, logs you want to keep - belongs under that folder. You can browse and edit it in the panel's Files tab or over SFTP.

#### The port

The panel gives every server an allocation: an address and a port, shown on the server's Network tab. That port is the only one reachable from outside, and it reaches your process through these environment variables:

| Variable | Value | Set when |
| --- | --- | --- |
| `SERVER_PORT` | The server's port | Always |
| `PORT` | The same port, unless your project sets `PORT` itself | The panel starts a linked repository |
| `HOST`, `HOSTNAME` | `0.0.0.0` | The panel starts a linked repository |

Read `SERVER_PORT` (or `PORT`) in your code. A port written into the code is reachable from inside the container and from nowhere else, unless it happens to match by coincidence. If your program needs a second port, add another allocation on the Network tab - a port that is not allocated is not reachable.

A web hosting server is the exception: its image already runs a web server bound to the port, so you do not bind anything.

#### Deploying from GitHub

Application servers deploy from GitHub through the panel's GitHub App, so private repositories work and no token is ever pasted into your code. On the server's GitHub tab you install the app, pick a repository and a branch, and choose two switches:

- **Pull the latest commit every time the server starts** - fetch the branch every time the server starts. With it off, the branch is fetched on the first start only and later starts use the files already there.
- **Restart the server when someone pushes to this branch** - when somebody pushes to the branch, the server restarts within seconds and comes back on the new commit. A stopped server is started, so the push goes live either way. Turning this on also fetches on every start.

The fetch writes the branch into the code folder (`/home/container` unless the GitHub tab says otherwise), over what is already there under the same names. Files elsewhere in the container are left alone. Where the image has `git`, the fetch is a shallow `git fetch` and `reset --hard`; on images without `git`, the branch is downloaded and unpacked over the folder, which means a file you deleted from the branch stays on the server until you remove it.

#### How a server boots

With a repository linked, a Node.js-capable server goes through these steps every time it starts:

1. The branch is fetched into the code folder (every start, or only the first - see above).
2. If the server type defines a setup command, it runs after the fetch.
3. The environment gets `PORT` (from `SERVER_PORT`, unless your project sets it), `HOST=0.0.0.0` and `HOSTNAME=0.0.0.0`.
4. If `package.json` defines a `start` script: `npm install`, then `npm run start` (Bun's own installer and runner on Bun images). The server type's own startup command is not run.
5. Otherwise, on Node.js, the first file that exists among `index.js`, `server.js`, `app.js`, `main.js`, `bot.js`, `index.mjs`, `index.cjs`, the same names under `src/`, and then their `.ts` versions, is started with `node` - or with `ts-node` for a `.ts` file.
6. If none of that applies, the server type's own startup command runs.

Other server types boot their own way. A Python server runs the entry file named on the Startup tab after installing `requirements.txt` - see [Python](https://www.renode.space/docs/python). A web hosting server runs nothing from your repository - see [Static sites and web hosting](https://www.renode.space/docs/web-hosting). A database server starts its engine - see [Databases](https://www.renode.space/docs/databases).

#### Secrets and .env

A `.env` file is not in your repository, and the fetch does not create one. Upload yours through the Files tab into the code folder, and keep a copy somewhere other than the server. Alternatively, set values as variables on the Startup tab.

In the repository, add `.env` to `.gitignore` and commit a `.env.example` that lists every variable with no values. Read configuration from the environment and fail with a clear message when a required value is missing - it turns a confusing crash into a one-line fix.

#### When changes go live

| Your setting | After a push |
| --- | --- |
| Restart on push on | The server restarts by itself within seconds, on the new commit |
| Pull on every start on, restart on push off | Restart the server when you want the new commit |
| Both off | Later starts keep the files from the first fetch |
| Web hosting | The panel writes the files on push (with restart on push on) and when you save the GitHub tab. A restart fetches nothing |

#### Memory and crashes

Each plan has a memory limit, and it is a hard ceiling. When a process reaches it, the kernel stops the container and it restarts clean - there is no stack trace and no final line, the console just stops. Build steps are the usual cause on small plans: a Next.js or TypeScript build can need more memory than the app itself. If the build is what gets killed, build locally and commit the output, or move to a larger plan.

The panel watches for crash loops. A server that restarts by itself three times in an hour gets a warning on its page and a support ticket; at six it is suspended. Restarts you asked for are not counted.

#### Check before you deploy

Once a repository is linked, the panel can run a **pre-flight check**: it reads the repository and reports what will break - a missing start script, a hardcoded port, a build that never runs, a Node version mismatch, a project that expects a `.env`. There is no AI involved; it is the panel reading your files against the way this server boots.

#### Where next

- [Node.js](https://www.renode.space/docs/nodejs) - start scripts, frameworks, TypeScript and Node versions.
- [Python](https://www.renode.space/docs/python) - entry files, `requirements.txt` and production servers.
- [Static sites and web hosting](https://www.renode.space/docs/web-hosting) - serving files as they are.
- [Discord and Telegram bots](https://www.renode.space/docs/bots) - tokens, restarts and state.
- [Databases](https://www.renode.space/docs/databases) - connecting an app, and where passwords live.
- [Custom domains](https://www.renode.space/docs/domains) - the proxy slot, DNS and certificates.
- [Troubleshooting](https://www.renode.space/docs/troubleshooting) - when a server will not start or cannot be reached.
- [Using AI assistants](https://www.renode.space/docs/ai-assistants) - the Connect AI link, connecting your account over MCP, and what an assistant may do.

### Deploy a Node.js app on Renode

Source: https://www.renode.space/docs/nodejs

A Node.js server runs your repository from `/home/container`. The reliable way to start anything on it - Express, Fastify, Next.js, NestJS, a bot - is a `start` script in `package.json` that listens on the port in `SERVER_PORT`.

#### How it starts

With a repository linked on the GitHub tab, every start goes like this:

1. The branch is fetched into `/home/container` (on every start if "Pull the latest commit every time the server starts" or "Restart the server when someone pushes" is on; otherwise on the first start only).
2. `PORT` is set to the server's port (unless your project sets `PORT` itself), and `HOST` and `HOSTNAME` to `0.0.0.0`.
3. If `package.json` has a `start` script: `npm install`, then `npm run start`.
4. Without a `start` script, the first file that exists among `index.js`, `server.js`, `app.js`, `main.js`, `bot.js`, `index.mjs`, `index.cjs`, then `src/index.js`, `src/server.js`, `src/app.js`, `src/main.js`, then the `.ts` versions of those names, is started with `node` - or `ts-node` for a `.ts` file.

Relying on step 4 is fragile. Define `start`.

`npm install` installs `devDependencies` too, so build tools such as `typescript` can stay there when the start script builds.

#### A minimal app

Two files are a complete, deployable app:

package.json:

```json
{
  "name": "hello-renode",
  "private": true,
  "engines": { "node": ">=20" },
  "scripts": {
    "start": "node index.js"
  }
}
```

index.js:

```js
const http = require("node:http");

// The server's port. PORT is set from SERVER_PORT when the panel starts the repo.
const port = Number(process.env.PORT || process.env.SERVER_PORT);

if (!port) {
  console.error("No port: SERVER_PORT is not set");
  process.exit(1);
}

http
  .createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Hello from Renode\n");
  })
  .listen(port, "0.0.0.0", () => {
    console.log(`Listening on 0.0.0.0:${port}`);
  });
```

With Express the only lines that matter are the same two: read the port from the environment and bind `0.0.0.0`.

server.js:

```js
const express = require("express");

const app = express();
app.get("/", (req, res) => res.send("Hello from Renode"));

app.listen(Number(process.env.PORT || process.env.SERVER_PORT), "0.0.0.0");
```

#### Frameworks that need a build

There is no separate build server: the build runs inside your server, with its memory and CPU. Put the build in the start script, or build locally and commit the output.

| Framework | A start script that works |
| --- | --- |
| Next.js | `next build && next start -H 0.0.0.0 -p $SERVER_PORT` |
| Nuxt | `nuxt build && node .output/server/index.mjs` |
| Vite (preview server) | `vite build && vite preview --host 0.0.0.0 --port $SERVER_PORT` |
| Angular | `ng build && npx http-server dist -a 0.0.0.0 -p $SERVER_PORT` |
| TypeScript | `tsc && node dist/index.js` |

Never use a development server as `start` - `next dev`, `vite`, `nodemon`. They are slow, unminified, watch the filesystem, use more memory, and often bind `localhost`, where nothing outside the container can reach them.

> [!WARNING]
> Next.js and other bundler builds can need more memory than a small plan has. If the console stops part-way through the build with no error, the build was killed at the memory limit. Build locally and commit the output (for Next.js, the `.next` folder, with `"start": "next start -H 0.0.0.0 -p $SERVER_PORT"`), or use a larger plan.

#### TypeScript

Compile in the start script, or commit the compiled `dist/`. Do not depend on a global `ts-node`.

package.json:

```json
{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "tsc -p tsconfig.json && node dist/index.js"
  },
  "devDependencies": { "typescript": "^5.6.0" }
}
```

#### Choosing the Node version

The Node.js version is the server's runtime image. Pick it under **Runtime version** on the GitHub tab, or the Docker image on the Startup tab. Declare the version your project expects in `engines.node` in `package.json` or in an `.nvmrc` file - the pre-flight check compares them with the server's runtime and warns when they differ, because packages built for one Node major can fail to install or run on another.

#### Environment variables

Upload `.env` through the Files tab into `/home/container`, or set variables on the Startup tab. If you use `dotenv`, load it first:

```js
require("dotenv").config();
```

Commit a `.env.example` with every key and no values, and keep `.env` in `.gitignore`.

#### Checklist

- `package.json` has a `start` script that starts the production server.
- The app listens on `process.env.PORT` or `process.env.SERVER_PORT`, on `0.0.0.0`.
- Anything that must be built is built by the start script, or the output is committed.
- `package-lock.json` is committed; `node_modules` is not.
- No password, token or key is committed; `.env` is in `.gitignore`.
- The Node version in `engines.node` or `.nvmrc` matches the server's runtime.
- Files the app writes at run time go under `/home/container`.

#### Common errors

**The container starts and immediately exits.** There is no `start` script and none of the entry files exist. Add `"start": "node <your main file>"`.

**`npm run start` cannot find `dist/index.js` or `.next`.** The build never ran. Build in the start script, or commit the output.

**The console says it is listening, but the address does not answer.** The app is bound to `localhost` or to a hardcoded port. Use `0.0.0.0` and the port from the environment.

**The console stops mid-build with no error.** The build hit the memory limit. See the warning above.

**Packages fail to install or crash with a native-module error.** The Node version differs from what the project was built with. Match the runtime version to `engines.node`, and never commit `node_modules` built on Windows or macOS.

More in [Troubleshooting](https://www.renode.space/docs/troubleshooting).

### Deploy a Python app on Renode

Source: https://www.renode.space/docs/python

A Python server runs one file from `/home/container`: the entry file named on the server's Startup tab. Before it starts, it installs `requirements.txt` when your repository has one.

#### How it starts

1. The branch is fetched into `/home/container` (on every start, or only the first - see the [overview](https://www.renode.space/docs/overview)).
2. `requirements.txt` at the repository root is installed with `pip`. Packages go into the user's site directory, so nothing needs root.
3. The entry file named on the Startup tab (often the `PY_FILE` variable) is run.

When you link a repository, the panel points the Startup tab at the first of `main.py`, `bot.py`, `app.py`, `run.py` or `__main__.py` that exists. Any other name works too - set it on the Startup tab.

Two things that are **not** read:

- **`pyproject.toml` dependencies.** Only `requirements.txt` is installed. Export them: `pip freeze > requirements.txt`, or `uv pip compile pyproject.toml -o requirements.txt`.
- **A `Procfile`.** The server runs the entry file, not the command in a `Procfile`.

#### A minimal web app

Start a production server from the entry file itself, bound to `0.0.0.0` and the port in `SERVER_PORT`. With FastAPI:

requirements.txt:

```text
fastapi==0.115.0
uvicorn==0.30.6
```

main.py:

```python
import os

import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def home():
    return {"hello": "renode"}


if __name__ == "__main__":
    # The server's port. Never hardcode one, never bind 127.0.0.1.
    uvicorn.run(app, host="0.0.0.0", port=int(os.environ["SERVER_PORT"]))
```

With Flask, use a production WSGI server rather than `app.run()`, which is Flask's development server:

requirements.txt:

```text
flask==3.0.3
waitress==3.0.0
```

app.py:

```python
import os

from flask import Flask
from waitress import serve

app = Flask(__name__)


@app.get("/")
def home():
    return "Hello from Renode"


if __name__ == "__main__":
    serve(app, host="0.0.0.0", port=int(os.environ["SERVER_PORT"]))
```

Django works the same way: start `waitress` (or another production server) from the entry file, bound to `0.0.0.0` and `SERVER_PORT`, and add the domain you serve it on to `ALLOWED_HOSTS`.

#### Configuration and .env

Read configuration from the environment. Upload `.env` through the Files tab into `/home/container`, or set variables on the Startup tab, and load the file with `python-dotenv`:

```python
import os

from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ.get("API_TOKEN")
if not TOKEN:
    raise SystemExit("API_TOKEN is not set - upload .env through the Files tab")
```

#### Logging

The console shows what your process writes to stdout. Python buffers it when it is not attached to a terminal, so output can arrive late or not at all before a crash. Print with `flush=True`, or set `PYTHONUNBUFFERED=1`.

#### System packages

`apt` and other system package managers are not available in the container. Choose packages that ship wheels for Linux rather than ones that compile against system libraries at install time.

#### Checklist

- The entry file on the Startup tab exists in the repository.
- `requirements.txt` is at the repository root, with pinned versions.
- The app binds `0.0.0.0` and `int(os.environ["SERVER_PORT"])`, with a production server.
- No password, token or key is committed; `.env` is in `.gitignore`.
- Output is unbuffered so the console shows it.
- Files the app writes at run time go under `/home/container`.

#### Common errors

**`ModuleNotFoundError` on start.** The package is not in `requirements.txt`, or it is declared only in `pyproject.toml`.

**`can't open file 'main.py'`.** The Startup tab names a file the branch does not have. Set the entry file to the script that starts your app.

**Running, but the address does not answer.** The app is bound to `127.0.0.1`, or to a hardcoded port such as 5000 or 8000.

**Nothing appears in the console.** Output is buffered - see Logging above.

More in [Troubleshooting](https://www.renode.space/docs/troubleshooting).

### Host a Discord or Telegram bot on Renode

Source: https://www.renode.space/docs/bots

A bot runs on a [Node.js](https://www.renode.space/docs/nodejs) or [Python](https://www.renode.space/docs/python) server, and starts exactly the way those pages describe: a `start` script or entry file on Node.js, the entry file on the Startup tab on Python. What is different about a bot is what it does not need, and what it must not do.

#### What is different about a bot

- **It needs no inbound port.** A Discord bot and a Telegram bot using long polling both connect *out* to the provider. Nothing has to reach the server, so there is nothing to bind. (A Telegram bot using webhooks is a web app: it must listen on `SERVER_PORT` and needs an HTTPS address - see [Custom domains](https://www.renode.space/docs/domains).)
- **The token is a secret.** Read it from the environment - a `.env` uploaded through the Files tab, or a Startup tab variable. Never commit it and never log it. A token pushed to a public repository is usually found and abused within minutes.
- **It will be restarted.** A push with restart on push on restarts it; so does any other restart. Handle reconnects, and keep anything you need across restarts in files under `/home/container` or in a [database](https://www.renode.space/docs/databases), not in memory.

#### A Discord bot in Node.js

package.json:

```json
{
  "name": "my-bot",
  "private": true,
  "scripts": { "start": "node bot.js" },
  "dependencies": {
    "discord.js": "^14.16.0",
    "dotenv": "^16.4.0"
  }
}
```

bot.js:

```js
require("dotenv").config();
const { Client, Events, GatewayIntentBits } = require("discord.js");

const token = process.env.DISCORD_TOKEN;
if (!token) {
  console.error("DISCORD_TOKEN is not set - upload .env through the Files tab");
  process.exit(1);
}

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
});

client.once(Events.ClientReady, (c) => console.log(`Logged in as ${c.user.tag}`));

client.on(Events.MessageCreate, (message) => {
  if (message.content === "!ping") message.reply("pong");
});

client.login(token);
```

.env (uploaded through the Files tab, never committed):

```text
DISCORD_TOKEN=your-token-here
```

#### A Discord bot in Python

requirements.txt:

```text
discord.py==2.4.0
python-dotenv==1.0.1
```

bot.py:

```python
import os

import discord
from dotenv import load_dotenv

load_dotenv()

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)


@client.event
async def on_ready():
    print(f"Logged in as {client.user}", flush=True)


@client.event
async def on_message(message):
    if message.content == "!ping":
        await message.reply("pong")


client.run(os.environ["DISCORD_TOKEN"])
```

Set the entry file on the Startup tab to `bot.py` if the panel did not pick it already.

#### A Telegram bot in Python

Long polling needs no port and no domain:

requirements.txt:

```text
python-telegram-bot==21.6
python-dotenv==1.0.1
```

main.py:

```python
import os

from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes

load_dotenv()


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Hello from Renode")


app = Application.builder().token(os.environ["TELEGRAM_TOKEN"]).build()
app.add_handler(CommandHandler("start", start))
app.run_polling()
```

#### Checklist

- The bot starts from a `start` script (Node.js) or the entry file on the Startup tab (Python).
- The token is read from the environment, is not committed and is never printed.
- `.env` is in `.gitignore`, and uploaded through the Files tab.
- State that must survive a restart is in files under `/home/container` or in a database.
- The bot reconnects on its own after a disconnect.

#### Common errors

**The console shows the bot logging in, then nothing happens.** The process is alive. Check the provider's side before the code: on Discord, the privileged intents (Message Content, Server Members) must be switched on in the Developer Portal for the bot to receive those events.

**`DISCORD_TOKEN is not set`, or an invalid token error.** The `.env` was not uploaded, is not in `/home/container`, or has a stray space or quote around the value.

**The bot replies twice.** Two copies are running - for example the same token on your own computer and on the server. Stop one.

**It forgets everything after a restart.** State was kept in memory. Write it to a file or a database.

### Connect your app to a database on Renode

Source: https://www.renode.space/docs/databases

There are two kinds of database on RE:NODE, and they are reached differently.

| | Database slot | Database hosting server |
| --- | --- | --- |
| What it is | A MySQL-compatible database the panel creates for an app or web server | A whole server running PostgreSQL, MySQL, MongoDB, Microsoft SQL Server or Valkey |
| Where it is created | The **Databases** tab of your app or web server | Bought as its own server |
| Connection details | Host, port, database and username on the Databases tab | The server's address and port, shown in the panel |
| Password | On the Databases tab | A Startup tab variable on the database server |

#### Database slots

App and web plans include database slots. On the server's **Databases** tab, create a database and the panel generates its host, username and password - nothing is shared with anybody else's server. The tab shows the host, port, database name and username; the password is revealed there too (you need the permission to view it if you are a subuser). **Open in phpMyAdmin** signs you straight into that database.

#### Database hosting servers

A database server runs its engine, started by its image. There is no application code to deploy to it. Connect from your app using the database server's address and port as shown in the panel. Its superuser or root password is generated when the server is created and lives in a variable on that server's **Startup** tab - copy it from there.

The engine's major version is fixed by the server's image and cannot be downgraded: a newer major changes the on-disk format, and an older one refuses to open it.

#### Keep the credentials out of the code

Connection settings belong in the app's environment, never in its repository. Put them in a `.env` uploaded through the Files tab of the **app** server, or in Startup tab variables:

.env (uploaded through the Files tab, never committed):

```text
DB_HOST=the-host-from-the-panel
DB_PORT=3306
DB_NAME=s12_app
DB_USER=u12_abc123
DB_PASSWORD=copied-from-the-databases-tab
```

The values above are placeholders - use the ones the panel shows for your database. Commit a `.env.example` with the same keys and no values.

#### Connecting from Node.js

With `mysql2` for a database slot or a MySQL server:

db.js:

```js
require("dotenv").config();
const mysql = require("mysql2/promise");

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

module.exports = pool;
```

With `pg` for a PostgreSQL server, the same values as one connection string:

```js
const { Pool } = require("pg");

// DATABASE_URL=postgres://user:password@host:port/database
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
```

#### Connecting from Python

db.py:

```python
import os

import pymysql
from dotenv import load_dotenv

load_dotenv()

connection = pymysql.connect(
    host=os.environ["DB_HOST"],
    port=int(os.environ["DB_PORT"]),
    database=os.environ["DB_NAME"],
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
)
```

For PostgreSQL use `psycopg` with a `DATABASE_URL`; for MongoDB, `pymongo` with a `mongodb://` URI built from the same environment values.

#### Notes per engine

- **Microsoft SQL Server** is the Express edition. In SSMS and most drivers the server name is `host,port` - with a comma. Express limits each database to 10 GB.
- **Valkey** is the open-source fork of Redis and speaks the Redis protocol, so Redis clients connect to it. It is password-protected.
- **MySQL** database servers come with a root password, an application database and an application user, all generated.

#### Checklist

- No password, host or connection string is written into the code or committed.
- The app reads its connection settings from the environment and fails clearly when one is missing.
- The password was copied from the Databases tab (slots) or the Startup tab (database servers).
- A linked repository on a database server holds migrations or seed files, not something to execute.

#### Common errors

**Access denied.** The password was mistyped or has a stray quote in `.env`. Copy it again from the panel.

**Connection refused or timed out.** The host or port is wrong - use exactly what the panel shows. `localhost` is never right for a database on another server.

**`database files are incompatible with server`.** A database server's data directory was opened by a different major version. Majors cannot be switched on an existing server, and there is no downgrade - open a support ticket rather than changing the image again.

### Use your own domain on Renode

Source: https://www.renode.space/docs/domains

App and web plans include a reverse-proxy slot. It accepts requests for your domain on the public address, terminates HTTPS there, and forwards plain HTTP to the one port your app listens on. Certificates are issued and renewed for you. RE:NODE does not sell domains or run DNS, so the records are created wherever your domain's DNS lives.

#### Adding a domain

1. On your server's Proxy tab, add the hostname - for example `app.example.com`.
2. At your DNS provider, create an **A record** for that name pointing at the address the proxy slot shows.
3. Wait for the name to resolve. Once it does, the certificate is issued automatically.

Renewal runs automatically inside the last 21 days of the certificate's life, so there is nothing to do each year.

| Name | Type | Value |
| --- | --- | --- |
| `app` | A | The address shown on the proxy slot |
| `@` (the apex) | A | The address shown on the proxy slot |
| `www` | CNAME | `example.com`, or a second A record |

Add **both** names to the proxy slot if both should work - a certificate covers only the names it was issued for. The apex cannot be a CNAME.

#### Cloudflare

If your DNS is on Cloudflare, set the record to **DNS only** (the grey cloud), not proxied. Cloudflare's proxy answers the certificate challenge itself, so the certificate for your server can never be issued, and it only carries HTTP and HTTPS on a fixed list of ports.

#### What your app sees behind the proxy

- **Plain HTTP, on its own port.** TLS ends at the proxy. An app that redirects every non-HTTPS request to HTTPS will loop for ever - trust the proxy instead. In Express that is `app.set("trust proxy", true)`; in Django, `SECURE_PROXY_SSL_HEADER`.
- **The visitor's address in `X-Forwarded-For`**, not in the connection itself.
- **The domain in `Host`.** Frameworks with an allowed-hosts list (Django's `ALLOWED_HOSTS`, for one) must include it.

Your app still listens on `0.0.0.0` and `SERVER_PORT` exactly as before - the proxy finds it there.

#### Game servers and databases

The proxy slot carries web traffic. A game server is not reached through it: point an A record straight at the server's address and players connect to the game port. Database hosting servers have no proxy slot; apps connect to their address and port directly.

#### Why a certificate is not issued

In the order they actually occur:

1. **The name does not resolve here yet.** Check with `dig +short app.example.com A @1.1.1.1` (or `nslookup app.example.com 1.1.1.1` on Windows), not your browser. If the record is right and the answer is old, wait out the record's TTL.
2. **It resolves somewhere else.** An old A record from a previous host is still there, or there are two. Delete the old one rather than adding a second.
3. **A stale AAAA record.** If the server has no IPv6 address, an AAAA record left from a previous host is tried first and fails. Delete it.
4. **The record is proxied by a CDN.** See Cloudflare above.
5. **A CAA record names another certificate authority.** Check with `dig +short example.com CAA`.
6. **Port 80 does not reach the proxy.** Validation happens over HTTP on port 80 for that hostname.

Fix the cause, then try once - certificate authorities rate-limit repeated failures.

### Troubleshooting a Renode deploy

Source: https://www.renode.space/docs/troubleshooting

Start with the console: it shows what your process printed, and the last lines before it stopped are usually the whole answer. Then run the **pre-flight check** on the GitHub tab, which reads your repository and names what will break. The sections below are the failures that come up again and again, with the fix for each.

#### The server will not start

**It starts and immediately exits (Node.js).** There is no `start` script in `package.json`, and none of the entry files the server falls back to exists. Add `"start": "node <your main file>"`. See [Node.js](https://www.renode.space/docs/nodejs).

**`can't open file` (Python).** The entry file on the Startup tab is not in the branch. Point it at the script that starts your app. See [Python](https://www.renode.space/docs/python).

**`Cannot find module` or `ModuleNotFoundError`.** A dependency is missing from `package.json` or `requirements.txt`. On Python, dependencies declared only in `pyproject.toml` are not installed.

**The console stops mid-sentence with no error.** The process reached the memory limit and was killed. There is never a message for this. The usual culprit is a build step on a small plan - build locally and commit the output, or use a larger plan.

**It stops because a setting is missing.** Your app expects a `.env` that is not there. The fetch never creates one: upload it through the Files tab into the code folder, or set the values on the Startup tab.

**The server restarts again and again.** Read the console for the error that ends each run. Three restarts by itself in an hour put a warning on the server's page and open a support ticket; six suspend it. Restarts you asked for are not counted.

#### The port is not reachable

The console says the app is listening, but the address does not answer.

- **It is bound to `localhost` or `127.0.0.1`.** That is reachable from inside the container only. Bind `0.0.0.0`.
- **The port is hardcoded** - 3000, 5000, 8000, 8080. Only the server's own port is reachable. Read `SERVER_PORT` (or `PORT`).
- **It runs a development server** - `next dev`, `vite`, `flask run`, `app.run()`. Those usually bind `localhost` and are not meant for production. Use the production server.
- **You need a second port.** A port that is not allocated to the server is not reachable. Add an allocation on the Network tab.
- **You are using a domain.** Check the domain resolves to the address on the proxy slot - see [Custom domains](https://www.renode.space/docs/domains).

#### The build is missing

**`next start` cannot find `.next`, or `node dist/index.js` cannot find the file.** Nothing built the project. Put the build in the start script (`"start": "next build && next start -H 0.0.0.0 -p $SERVER_PORT"`, `"start": "tsc && node dist/index.js"`), or build locally and commit the output.

**A web hosting site shows a placeholder or a directory listing.** The served folder has no `index.html` at its root. Commit the built `dist/` folder and point the path on the GitHub tab at it. Web hosting never runs a build. See [Static sites and web hosting](https://www.renode.space/docs/web-hosting).

#### The wrong Node version runs

The Node.js version is the server's runtime image, not something your repository chooses. Pick a version under **Runtime version** on the GitHub tab, or the Docker image on the Startup tab, that matches `engines.node` in `package.json` or your `.nvmrc`. The pre-flight check warns when they differ. Native modules built for one Node major can fail to install or crash on another, and a committed `node_modules` built on Windows or macOS will not run at all - do not commit it.

#### A push did not show up

- **Restart on push is off.** Restart the server yourself, or tick "Restart the server when someone pushes to this branch" on the GitHub tab.
- **Pulling on every start is off** and so is restart on push. Only the first start fetched the branch. Turn either switch on.
- **It is a web hosting server.** A restart fetches nothing there. Save the GitHub tab, or turn restart on push on.
- **You pushed to a different branch** from the one the GitHub tab names.

#### A deleted file is still there

On images without `git`, and on web hosting, the branch is unpacked over the folder, so a file removed from the branch is not removed from the server. Delete it through the Files tab.

#### Still stuck

Open a ticket from the panel - it reaches all staff, and attachments stay private. You can also ask on [Discord](https://discord.com/invite/sfvbC9jkwr) or write to renodespace@gmail.com. On the Console tab, the [AI](https://www.renode.space/docs/ai-assistants) purpose "Server won't start" copies a prompt with the latest console lines, which is the fastest way to hand the problem to an assistant.

## Panel how-to

From the panel guides at https://www.renode.space/guides.

### Ordering a server

Ordering takes about a minute and happens in three screens: find what you want to run, pick a plan, and pay. Pay by card or PayPal and the server is set up the moment the payment goes through - you can watch it install in its console.

- Takes: About a minute
- Pay with: Card, PayPal or bank transfer
- Billed for: 1, 3, 6, 12 or 24 months
- Prices in: US dollars or Georgian lari

Open the shop and find what you want to run:

1. Sign in to the panel and open the shop. The tabs along the top are the product lines: VDS, Game Servers, App Hosting, Web Hosting and Databases.
2. Find your game - there is a search box - and press View plans. The card already shows the lowest price and the range of memory on offer.

Compare the plans and pick one:

3. Every plan lists its memory, CPU and NVMe storage, roughly how many players it suits, and what is included - updates, backup slots, file and SFTP access.
4. Press Get Started on the one you want. Not sure? Start smaller: you can move to a bigger plan later without losing your world.

Choose how long, then pay:

5. Pick a billing period - 1, 3, 6, 12 or 24 months. The total for the whole period is shown before you pay, so there are no surprises.
6. Pay with PayPal, or press Debit or Credit Card - paying by card does not need a PayPal account. The server is created as soon as the payment completes.

Paying by bank transfer instead: Prefer to pay from your bank? Choose bank transfer at checkout. The server is set up once the payment arrives, usually the next working day.

1. Switch the payment tab to Bank transfer. The account details for Bank of Georgia and TBC Bank appear, each with a Copy button.
2. Press Order & get invoice, then pay that invoice from your banking app, quoting the reference on it.

After you pay:

- The server appears under My Servers and installs itself. Open it to watch the install in the console.
- Your orders, invoices and subscriptions are under Finances in the side menu.
- Stuck at any step? Open Support in the side menu and raise a ticket.

### The console

Live output, a command line, power controls and the resource graphs. It is the server's own output rather than a filtered summary - if the game prints it, you see it.

- Start, stop, restart and kill, with the current state always visible.
- Command history with the arrow keys, and tab completion for common commands.
- Web-server noise - the internet knocking on doors that are not there - is folded behind a count you can switch off. A real error always prints.
- Graphs for memory, CPU and disk against your limits, so whether it is the plan or the mod is answerable.

### Files and SFTP

A complete file manager with an editor in the browser, plus SFTP credentials for the tools you already use. Upload a modpack, fix a config, pull a world down to your machine.

- Drag files in, or upload a folder. Archives can be unpacked in place.
- Edit any text file with syntax highlighting and save without downloading it.
- PDFs and images preview in the panel rather than downloading first.
- SFTP on the same credentials - use FileZilla, WinSCP or your editor's remote mode.

### Backups

Take one before you try something. Restore it when the something goes wrong. Lock the ones that matter so they are not rotated away.

- Slots on every plan - two on the entry tiers, more further up.
- Take one on demand, or on a schedule you set.
- Download any backup to your own machine.

> Important: Deleting a server deletes its backups, including the locked ones. A lock stops you removing a backup by accident; it is not a claim on the disk after the server is gone. Download what you want to keep before you cancel.

### Databases

Create a database from the panel and it comes with a host, a username and a password generated for it. Nothing is shared with anybody else's server.

> Open in phpMyAdmin signs you straight into your own database. The credentials never reach your browser and never appear in a URL: the panel mints a token that works once and expires in sixty seconds.

### Subusers, roles and teams

Give somebody the console without giving them your files, or the files without your billing. Three separate ideas, kept apart deliberately.

- A role holds permissions - what may be done.
- A team holds people.
- A grant joins a team to a server with a role, so adding a moderator to the team gives them the same access everywhere at once.
- Invite by link, by a join code, or by email address - and require approval on the codes if you would rather vet people.
- Access can be time-boxed, and expiry stops working immediately rather than overnight.

### Scheduled tasks

Restart nightly, back up before the restart, announce it to players first. Tasks run in order with delays between them, so a schedule can be a small routine rather than one action.

- Send a console command - a warning to players, a save, a world flush.
- Take a backup.
- Power actions: start, stop, restart.

### Git deploys

For application servers: connect a GitHub repository and branch, and the container pulls it on boot. Switch on deploy-on-push and it restarts itself when you push - and a stopped server is started, so the push goes live either way.

Every deploy is recorded: opened when the push arrives, closed once the container is actually seen running again. That is the difference between a deploy that shipped and a deploy that fell over.

[Read the step-by-step guide](https://www.renode.space/blog/deploy-a-node-app-from-github)

## Tutorials

72 posts, newest first. The newest 14 follow in full after the list; open the link for the rest.

- [Cannot find module @rollup/rollup-linux-x64-gnu: the fix](https://www.renode.space/blog/cannot-find-module-rollup-linux-x64-gnu) (2026-10-15): Why Vite and Rollup fail with Cannot find module @rollup/rollup-linux-x64-gnu, musl, arm64 or win32, and how to fix the lockfile, Docker and CI for good.
- [Discord webhooks explained: create, send, edit, secure](https://www.renode.space/blog/discord-webhooks-explained) (2026-10-15): What a Discord webhook is, how to create one, and how to post, edit and delete messages with curl, Python and JavaScript - plus threads, files, limits and leaks.
- [Docker healthcheck interval, start period and retries](https://www.renode.space/blog/docker-healthcheck-interval-and-start-period) (2026-10-15): How Docker HEALTHCHECK timing works: interval, timeout, retries, start_period and start_interval, Dockerfile vs Compose syntax, and why unhealthy never restarts.
- [PM2 guide: ecosystem config, startup, logs and reload](https://www.renode.space/blog/pm2-process-manager-guide) (2026-10-15): What PM2 is and how to run it properly: pm2 start, ecosystem.config.js, cluster mode, pm2-logrotate, pm2 save and pm2 startup, reload vs restart, and Python apps.
- [Telegram webhook not working: reading getWebhookInfo](https://www.renode.space/blog/telegram-bot-webhook-troubleshooting) (2026-10-15): Fix a Telegram bot webhook that receives nothing: what each getWebhookInfo field means, the common last_error_message values, the backlog, and the fixes.
- [Bun or Deno on a small server: which runtime to pick](https://www.renode.space/blog/bun-or-deno-on-a-small-server) (2026-10-14): Bun vs Deno vs Node.js for a small hosted app: Bun.serve, Deno.serve, permission flags, TypeScript without a build, npm compatibility and what breaks.
- [Deploy a Go web service: net/http, chi and gin in production](https://www.renode.space/blog/deploy-a-go-web-service) (2026-10-14): Take a Go web service from go.mod to a running app server: building on start, PORT and binding, graceful shutdown, GitHub deploys, proxy headers and memory.
- [Deploy a Spring Boot app to production on a Java server](https://www.renode.space/blog/deploy-spring-boot-app-to-production) (2026-10-14): Run a Spring Boot, Quarkus or Micronaut fat jar in production: building the jar, port binding, JVM heap sizing, HikariCP, Actuator health and proxy headers.
- [ASP.NET Core authentication: cookies, JWT and Identity](https://www.renode.space/blog/aspnet-core-authentication-basics) (2026-10-08): Choose between cookie and JWT authentication in ASP.NET Core, set up Identity, and persist data protection keys so redeploys stop signing everyone out.
- [ASP.NET Core behind a reverse proxy: forwarded headers](https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy) (2026-10-08): Make ASP.NET Core see the real client IP and HTTPS behind a proxy: UseForwardedHeaders, KnownProxies, redirect loops, HSTS, WebSockets and timeouts.
- [ASP.NET Core configuration and secrets, from laptop to server](https://www.renode.space/blog/aspnet-core-configuration-and-secrets) (2026-10-08): How ASP.NET Core configuration layers: appsettings.json, environments, environment variables with __, user-secrets, the options pattern and startup validation.
- [ASP.NET Core logging and health checks in production](https://www.renode.space/blog/aspnet-core-logging-and-health-checks) (2026-10-08): ILogger and log levels, structured messages, Serilog with sane sinks, request logging, and health check endpoints that tell the truth about your app.
- [ASP.NET Core performance on small servers](https://www.renode.space/blog/aspnet-core-performance-on-small-servers) (2026-10-08): Make ASP.NET Core fast on 1-2 vCPU and a few GB of RAM: Kestrel limits, response compression, output caching, thread pool starvation and allocations.
- [Blazor Server vs WebAssembly: what each costs to host](https://www.renode.space/blog/blazor-server-vs-webassembly-hosting) (2026-10-08): How Blazor Server circuits and Blazor WebAssembly differ once hosted: memory per user, SignalR, latency, download size, prerendering and the Blazor Web App modes.
- [Deploy an ASP.NET Core app from GitHub to a server](https://www.renode.space/blog/deploy-aspnet-core-app-from-github) (2026-10-08): Take an ASP.NET Core project from a GitHub repository to a running server: dotnet publish, the start command, Kestrel, the port, HTTPS and the first deploy.
- [Discord bot in C#: Discord.Net, DSharpPlus or NetCord](https://www.renode.space/blog/discord-bot-in-csharp) (2026-10-08): Build and host a Discord bot in C#: choosing Discord.Net, DSharpPlus or NetCord, gateway intents, slash commands, the Generic Host, and running it 24/7.
- [DKIM keys and rotation: selectors, key sizes, publishing](https://www.renode.space/blog/dkim-keys-and-rotation) (2026-10-08): How DKIM keys and selectors work, RSA 2048 versus Ed25519, publishing long keys in DNS, and rotating keys on a schedule without breaking mail in flight.
- [DMARC reports and policy rollout: from p=none to reject](https://www.renode.space/blog/dmarc-reports-and-policy-rollout) (2026-10-08): How to read DMARC aggregate reports, sort the senders they reveal, fix alignment, and move a domain from p=none to quarantine and reject without losing real mail.
- [.NET memory and garbage collection in containers](https://www.renode.space/blog/dotnet-memory-and-garbage-collection) (2026-10-08): Why a .NET app uses the memory it does: server vs workstation GC, DATAS, container limits, GCHeapHardLimit, reading the real numbers and finding a leak.
- [dotnet publish options: self-contained, single-file and AOT](https://www.renode.space/blog/dotnet-publish-and-runtime-options) (2026-10-08): What dotnet publish actually produces: framework-dependent vs self-contained, runtime identifiers, single-file, trimming, ReadyToRun and Native AOT, and which to use.
- [.NET versions, LTS vs STS and when to upgrade](https://www.renode.space/blog/dotnet-versions-and-lts-support) (2026-10-08): Which .NET version to run in production: LTS and STS support dates, what end of support means, upgrading target frameworks, global.json and roll-forward.
- [.NET with PostgreSQL, MySQL or SQL Server: providers](https://www.renode.space/blog/dotnet-with-postgresql-mysql-or-sql-server) (2026-10-08): Pick and wire up a database for a .NET app: EF Core providers, Npgsql, MySqlConnector and SqlClient, connection strings, pooling, and the differences that bite.
- [.NET worker services and background jobs: hosted to Hangfire](https://www.renode.space/blog/dotnet-worker-services-and-background-jobs) (2026-10-08): Run background work in .NET properly: BackgroundService, PeriodicTimer, in-process queues with Channels, Quartz.NET, Hangfire, error handling and clean shutdown.
- [Email client setup: IMAP and SMTP settings that work](https://www.renode.space/blog/email-client-setup-imap-smtp) (2026-10-08): Set up Thunderbird, Outlook, iPhone and Android mail on your own server: the right ports and TLS modes, usernames, and autoconfig so clients find it alone.
- [EF Core migrations in production: bundles, scripts, rollbacks](https://www.renode.space/blog/entity-framework-core-migrations-in-production) (2026-10-08): Apply Entity Framework Core migrations safely in production: Migrate at startup, migration bundles, idempotent SQL scripts, expand-contract changes and rollbacks.
- [IMAP vs POP3 vs JMAP: mail access protocols compared](https://www.renode.space/blog/imap-vs-pop3-vs-jmap) (2026-10-08): How IMAP, POP3 and JMAP differ: sync model, push, ports, client support and battery use, and which ones to enable on your own mail server.
- [Mail server spam filtering: scores, blocklists and training](https://www.renode.space/blog/mail-server-spam-filtering) (2026-10-08): How a mail server decides what is spam: score thresholds, DNS blocklists, greylisting, Bayesian training and how to find and fix false positives.
- [Migrating email to your own server without losing mail](https://www.renode.space/blog/migrating-email-to-your-own-server) (2026-10-08): Move mailboxes from Gmail, Microsoft 365 or a host to your own mail server: copy with imapsync, cut the MX over safely and keep every old message.
- [Minimal APIs in .NET: routes, validation and OpenAPI](https://www.renode.space/blog/minimal-apis-in-dotnet) (2026-10-08): Build a real HTTP API with .NET minimal APIs: parameter binding, TypedResults, route groups, validation, problem details, OpenAPI, and when controllers are better.
- [MTA-STS, DANE and TLS-RPT: enforcing TLS for email](https://www.renode.space/blog/mta-sts-and-tls-for-email) (2026-10-08): Why STARTTLS between mail servers can be stripped, and how MTA-STS, DANE and TLS-RPT make encrypted delivery to your domain mandatory and measurable.
- [MX records explained: priority, backup MX and null MX](https://www.renode.space/blog/mx-records-explained) (2026-10-08): How MX records route mail to your domain: priority numbers, what senders do when a host is down, backup MX, null MX, the CNAME rule and checking with dig.
- [Port 25 blocked: outbound mail, 587 vs 465 and relays](https://www.renode.space/blog/port-25-and-outbound-mail-blocks) (2026-10-08): Why networks block port 25, the difference between ports 25, 587 and 465, how to test for a block, and how to send mail anyway through a relay.
- [Reverse DNS for mail servers: PTR, FCrDNS and HELO](https://www.renode.space/blog/reverse-dns-ptr-for-mail-servers) (2026-10-08): What a PTR record is, who controls it, how forward-confirmed reverse DNS and the HELO name must line up, how to check them, and what to do if you cannot set one.
- [Self-hosted mail server: what it really takes](https://www.renode.space/blog/self-hosted-mail-server-guide) (2026-10-08): Running your own mail server honestly: the ports, the DNS records, deliverability, the daily upkeep, and the cases where you should not do it at all.
- [Hosting SignalR apps: transports, proxies and scale-out](https://www.renode.space/blog/signalr-real-time-apps-hosting) (2026-10-08): Run ASP.NET Core SignalR in production: transports and negotiation, WebSockets through a proxy, timeouts, auth tokens, and a Valkey backplane for scale-out.
- [SMTP relay for outgoing mail: smarthosts explained](https://www.renode.space/blog/smtp-relay-for-outgoing-mail) (2026-10-08): What an SMTP relay or smarthost does, when your mail server needs one, how to authenticate to it, and the SPF, DKIM and bounce settings that keep DMARC passing.
- [Stalwart mail server setup: domains, accounts and DKIM](https://www.renode.space/blog/stalwart-mail-server-setup) (2026-10-08): Setting up Stalwart from first sign-in: the host name, domains, accounts and aliases, DKIM keys, the DNS records to publish, ports, spam filtering and testing.
- [Transactional email from your app: SMTP done properly](https://www.renode.space/blog/transactional-email-from-your-app) (2026-10-08): Send password resets and receipts from Node, Python, Django or PHP over SMTP submission, with credentials kept safe, a queue in front and bounces handled.
- [Why email goes to spam, and how to fix each cause](https://www.renode.space/blog/why-email-goes-to-spam) (2026-10-08): The real reasons mail lands in spam: authentication, IP and domain reputation, blocklists, reverse DNS, content and volume - and how to diagnose which one is yours.
- [IP leaks and doxxing for server owners](https://www.renode.space/blog/ip-leaks-and-doxxing-for-server-owners) (2026-10-07): Your server's address is meant to be public; your home address and real name are not. Where owners leak them - DNS history, payments, usernames - and how to stop it.
- [Background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) (2026-09-21): Queues, schedulers and workers on one box: what to run in-process, when Postgres is enough, and how to stop jobs running twice.
- [Cloudflare for websites and game servers](https://www.renode.space/blog/cloudflare-for-websites-and-game-servers) (2026-09-21): The orange cloud carries HTTP and nothing else. What that means for a Minecraft server, an SRV record, SSL modes and your origin address.
- [Deploy a Rust web service with axum](https://www.renode.space/blog/deploy-a-rust-web-service) (2026-09-21): Putting an axum or actix-web service into production: release profiles, build time and RAM on small plans, binding, tokio threads and graceful shutdown.
- [Deploy Django to production with gunicorn](https://www.renode.space/blog/deploy-django-to-production) (2026-09-21): Take a Django project live: DEBUG and ALLOWED_HOSTS, gunicorn workers, collectstatic and WhiteNoise, migrations, proxy headers and secure cookies.
- [Deploy an Express API to production](https://www.renode.space/blog/deploy-express-api-to-production) (2026-09-21): The Express settings that only matter once the app is on a server: binding, trust proxy, middleware order, async errors, keep-alive 502s and clean shutdown.
- [Deploy FastAPI or Flask: uvicorn and gunicorn](https://www.renode.space/blog/deploy-fastapi-or-flask) (2026-09-21): Run a FastAPI or Flask app in production: ASGI vs WSGI, binding to 0.0.0.0, worker counts and memory, proxy headers, env vars and health checks.
- [Deploy Next.js on a Node server](https://www.renode.space/blog/deploy-nextjs-on-a-node-server) (2026-09-21): Self-hosting Next.js: why the build needs more memory than the app, standalone output and its missing folders, baked environment variables and the ISR cache.
- [Hosting a discord.py bot: cogs and intents](https://www.renode.space/blog/discord-py-bot-hosting-guide) (2026-09-21): Running a discord.py bot on a server: pinning requirements, intents, cogs and setup_hook, syncing the command tree, and the blocked event loop that kills bots.
- [SPF, DKIM and DMARC: keep mail out of spam](https://www.renode.space/blog/email-spf-dkim-dmarc-explained) (2026-09-21): The three DNS records that prove mail is really yours, how alignment ties them together, and the rollout order that does not break your sending.
- [Graceful shutdown and real health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) (2026-09-21): How SIGTERM, connection draining and a health endpoint that checks the right things turn a restart from a blip into nothing at all.
- [Host a Discord bot 24/7 with discord.js](https://www.renode.space/blog/host-a-discord-bot-24-7) (2026-09-21): Keeping a discord.js bot online for good: the token, intents, a start command that survives restarts, GitHub deploys, and the crashes that take bots offline.
- [Nameservers vs DNS records: who controls what](https://www.renode.space/blog/nameservers-vs-dns-records) (2026-09-21): Registrar, DNS host and web host are three different jobs. Which one to change when you move a site, and how to move any of them without downtime.
- [Nginx reverse proxy: server blocks and TLS](https://www.renode.space/blog/nginx-reverse-proxy-guide) (2026-09-21): Put nginx in front of your app: the minimal server block, proxy headers, certbot and renewal, websocket upgrades, upload limits and the causes of 502 and 504.
- [npm ci vs npm install: which to deploy with](https://www.renode.space/blog/npm-ci-vs-npm-install) (2026-09-21): What npm ci does differently, why the lockfile must be committed, how to keep devDependencies for the build and drop them after it.
- [PM2 vs a hosting panel: what each one does](https://www.renode.space/blog/pm2-vs-a-hosting-panel) (2026-09-21): What PM2 actually does, what a container-based panel already does, and why running a process manager inside one hides the crashes you most need to see.
- [Python requirements, virtualenvs and pins](https://www.renode.space/blog/python-requirements-and-virtualenvs) (2026-09-21): How to make a Python install reproducible: virtualenvs, pinned requirements.txt, pip-tools and uv lock files, and installs that do not break on a server.
- [Game server at home vs renting one](https://www.renode.space/blog/self-hosting-a-game-server-at-home-vs-renting) (2026-09-21): When a spare PC at home is genuinely enough, and when CGNAT, upload speed, electricity or your own IP address make renting the cheaper answer.
- [Telegram bot hosting: polling and webhooks](https://www.renode.space/blog/telegram-bot-hosting-guide) (2026-09-21): How updates reach a Telegram bot, when to use long polling and when to use a webhook, and how to run one on a server without the 409 conflict everyone hits.
- [WebSockets behind a reverse proxy: what breaks](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) (2026-09-21): Upgrade headers, the nginx map block, timeouts that kill a socket at 60 seconds, sticky sessions for Socket.IO, and how to read the errors.
- [www vs apex domain and how to redirect](https://www.renode.space/blog/www-vs-apex-domain-and-redirects) (2026-09-21): Pick one canonical host, serve the other as a redirect. Why a CNAME cannot sit at the apex, what to redirect with, and the loops to avoid.
- [Deploy a Node.js app from GitHub on push](https://www.renode.space/blog/deploy-a-node-app-from-github) (2026-08-11): From an empty server to a running Node app: the start command, the port, environment variables, a domain in front, and automatic redeploys on push.
- [Environment variables and secrets on a server](https://www.renode.space/blog/environment-variables-and-secrets) (2026-05-12): API keys, database passwords and bot tokens do not belong in your repository. Where they go instead, how to rotate one, and the ways they still leak.
- [Node.js memory limits: heap, RSS and 2 GB](https://www.renode.space/blog/node-memory-limits-explained) (2026-05-05): Why a Node app dies at 2 GB on a 4 GB plan: the V8 heap limit against the container limit, how to set --max-old-space-size, and how to find the real leak.
- [Point a domain at your server and add SSL](https://www.renode.space/blog/your-domain-and-its-certificate) (2026-04-14): The A record, the wait, and the certificate that is issued once the name resolves. What each step does, and the six reasons issuance actually fails.
- [Discord bot hosting: how much RAM you need](https://www.renode.space/blog/picking-a-plan-for-a-discord-bot) (2026-04-10): A command bot runs in 1 GB. A music bot does not. What the member cache, intents, voice streams and sharding really cost, and the plan most people over-buy.
- [What a reverse proxy does: TLS and real IPs](https://www.renode.space/blog/what-a-reverse-proxy-does) (2026-04-07): How a reverse proxy terminates TLS, routes by hostname and passes the real client IP in X-Forwarded-For, and what breaks when your app ignores it.
- [Moving a game server without losing players](https://www.renode.space/blog/moving-a-server-without-losing-players) (2026-02-13): The order of operations that makes a migration uneventful: what to copy, when to stop the old server, how DNS TTL really behaves, and how to roll back.
- [Zero-downtime deploys on one small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) (2026-01-23): No cluster needed. Where the gap during a deploy actually comes from, the three techniques that close it on one machine, and the migrations that undo them.
- [Static IP: when you need one and when not](https://www.renode.space/blog/static-ip-and-why-you-might-need-one) (2026-01-02): What a static or dedicated address actually buys you, why a hostname solves most of it, how to handle a dynamic address at home, and how to spot CGNAT.
- [DNS records: A, CNAME, SRV, TXT, MX and TTL](https://www.renode.space/blog/dns-records-explained) (2025-12-26): Every DNS record type you will actually create, how a lookup really works, why a CNAME cannot sit at the apex, and why your change has not taken effect yet.
- [Subdomains for game servers and apps](https://www.renode.space/blog/subdomains-for-servers) (2025-12-19): Why every server should have its own name, how to create the record, what a subdomain cannot carry, and the TTL routine that makes a migration boring.
- [Domain for a game server: A and SRV records](https://www.renode.space/blog/connecting-a-domain-to-a-game-server) (2025-12-12): A game server lives at an address and a port, and a domain carries only the address. The three ways to bridge that gap, and how to prove it worked.

---

### Cannot find module @rollup/rollup-linux-x64-gnu: the fix

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/cannot-find-module-rollup-linux-x64-gnu

`Error: Cannot find module @rollup/rollup-linux-x64-gnu` means Rollup's native binary for the machine you are on was never installed. Rollup 4 ships its parser as a separate compiled package per platform, npm installs only the one that matches, and three things reliably stop the right one arriving: a `package-lock.json` written on another operating system by an npm version with a known optional-dependency bug, a `node_modules` folder copied from another machine (very often into a Docker image), or an install run with optional dependencies switched off. The fix that works in nearly every case is the one Rollup prints itself: delete both `node_modules` and `package-lock.json`, run `npm install` again, and commit the new lockfile. The rest of this post is about doing that without breaking anything else, and about making sure it does not come back on the next deploy.

#### What the error actually says

The full message comes from Rollup's own loader, and it is unusually honest:

```text
Error: Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related
to optional dependencies (https://github.com/npm/cli/issues/4828). Please try
`npm i` again after removing both package-lock.json and node_modules directory.
```

The package name at the start is the important part. It tells you which platform Rollup decided it is running on, and therefore which binary it went looking for. Rollup builds the name from three things: the operating system, the CPU architecture, and on Linux the C library.

| Package suffix | Where you see it |
| --- | --- |
| `linux-x64-gnu` | Ordinary Linux servers, Debian and Ubuntu images, most CI runners |
| `linux-x64-musl` | Alpine Linux, including `node:*-alpine` images |
| `linux-arm64-gnu` | ARM servers, Docker on Apple Silicon, Raspberry Pi 64-bit |
| `linux-arm64-musl` | Alpine on ARM |
| `darwin-arm64` / `darwin-x64` | macOS on Apple Silicon / Intel |
| `win32-x64-msvc` | Windows on x64 |
| `win32-arm64-msvc` | Windows on ARM |

There are more - FreeBSD, RISC-V, s390x, 32-bit ARM and others - but the table covers almost every report you will find. The suffix is a diagnosis in itself. If your server is a normal x64 Linux box and the error names `linux-x64-musl`, you are running inside Alpine whether you meant to or not. If you are on Windows and the error names `win32-x64-msvc`, someone else's lockfile is in play.

On Linux, Rollup decides between `gnu` and `musl` at runtime by asking Node for its process report: if the report carries a glibc version, it is `gnu`, otherwise `musl`. You do not configure this, and you cannot override it with an environment variable.

#### Why Rollup needs a binary at all, and why Vite brings it in

Rollup 3 was pure JavaScript. Rollup 4, released in October 2023, replaced its JavaScript parser with SWC's, compiled from Rust into a native Node add-on. That made parsing much faster and introduced a problem every native package has: one compiled file cannot run on every platform.

The pattern Rollup uses, shared by esbuild, SWC, Lightning CSS, Tailwind CSS 4's `@tailwindcss/oxide` and sharp's `@img/*` packages, is to publish one tiny package per platform and list all of them in the main package's `optionalDependencies`. Each platform package declares the operating system and CPU it supports (and, for the Linux builds, which C library) in its own `package.json`:

node_modules/@rollup/rollup-linux-x64-gnu/package.json (excerpt):

```json
{
  "name": "@rollup/rollup-linux-x64-gnu",
  "os": ["linux"],
  "cpu": ["x64"],
  "libc": ["glibc"],
  "main": "./rollup.linux-x64-gnu.node"
}
```

When npm installs `rollup`, it sees twenty-odd optional dependencies, checks each one's `os` and `cpu` against the current machine, quietly skips the ones that do not match, and installs the one that does. Optional means a failure or a mismatch is not an error. That is exactly what lets this work across platforms, and exactly why it fails silently when it goes wrong: npm never tells you it skipped the one package you needed.

Most people never install Rollup on purpose. It arrives with Vite. Vite 5, 6 and 7 all depend on Rollup 4 for production builds, so `vite build` is where the error appears, usually in a CI job or on a server, rarely on the laptop where the project was set up. Upgrading from Vite 4 to Vite 5 was the moment a lot of projects met it for the first time, because Vite 4 still used Rollup 3 and had no platform binaries of its own beyond esbuild's.

Vite 8 replaces Rollup with Rolldown, which uses the same mechanism under different names: `@rolldown/binding-linux-x64-gnu` and friends. If you are on Vite 8 and see a missing native binding error from Rolldown, everything in this post applies - only the package names change.

#### The npm bug behind most cases

The issue Rollup links to, npm/cli#4828, was opened in April 2022 and describes this: when npm regenerates `package-lock.json` while a `node_modules` folder is already present, it builds the lockfile from what is installed rather than from the full dependency metadata. On a Mac that means the lockfile ends up listing `@rollup/rollup-darwin-arm64` and none of the other platform packages, because they were never installed there.

That lockfile is then committed. On Linux, `npm ci` installs exactly what the lockfile lists - which does not include a Linux binary - and finishes without a warning. The first sign of trouble is the build.

```diagram
title: How a Mac lockfile breaks a Linux build
mac = Laptop (macOS) | node_modules present
lock = package-lock.json | lists darwin-arm64 only
git = Git repository
server = Linux server | npm ci
build = vite build | binary missing
mac -> lock : rm lockfile, npm install
lock -> git : committed
git -> server
server -> build : no linux-x64-gnu
```

The usual trigger is innocent: someone deletes only the lockfile to fix a merge conflict or a peer dependency warning, runs `npm install` with `node_modules` still in place, and commits the result. It also happens on Windows, which is how Linux-generated lockfiles break Windows developers in the other direction with `win32-x64-msvc`.

The state of the fix is messy. Reports on the issue say recent npm 11 releases no longer drop the other platforms for packages that declare all of them as optional dependencies, which is Rollup's case, and other reports describe the problem returning in later 11.x releases. Do not plan around a particular npm version fixing it. Plan around a lockfile you have checked.

#### Confirm the cause before you change anything

Two commands tell you which of the three causes you have. First, on the machine that fails, ask npm whether the binary is installed:

```bash
$ npm ls @rollup/rollup-linux-x64-gnu
my-app@1.0.0 /srv/app
└── (empty)
```

An empty result means npm never installed it anywhere in the tree. Then look at the lockfile itself and count the platform entries:

```bash
$ grep -c '"node_modules/@rollup/rollup-' package-lock.json
1
```

A healthy lockfile lists every platform Rollup publishes, so the count is around twenty depending on the Rollup version. A count of one or two is the npm bug: the lockfile was generated on one machine and only knows that machine's binary. To see which one it does know:

```bash
$ grep '"node_modules/@rollup/rollup-' package-lock.json
    "node_modules/@rollup/rollup-darwin-arm64": {
```

If the lockfile is complete and the package is still missing, the lockfile is not your problem. Look at how `node_modules` got onto the machine, and at whether optional dependencies are switched off - the next sections cover both.

> [!TIP]
> Run the `grep -c` check in CI. A lockfile that lists one Rollup platform is a lockfile that will break the next machine with a different operating system, and it is far cheaper to fail a pull request than a deploy.

#### The fix: regenerate the lockfile properly

On the machine where you normally change dependencies:

```bash
$ rm -rf node_modules package-lock.json
$ npm install
$ grep -c '"node_modules/@rollup/rollup-' package-lock.json
$ git add package-lock.json
$ git commit -m "fix: regenerate lockfile with all platform binaries"
```

On Windows in PowerShell, the first line is `Remove-Item -Recurse -Force node_modules, package-lock.json`. Deleting `node_modules` is the part that matters. Deleting only the lockfile with `node_modules` still present is precisely what triggers the bug.

This fix has a cost, and it is worth being clear about. Without a lockfile, `npm install` re-resolves every version range in `package.json` against what is published today. A `^5.4.0` that was pinned at 5.4.2 can come back as 5.4.19. For most projects that is harmless, but it is not nothing: read the lockfile diff, run the tests and a build, and deploy it as a dependency update rather than as a one-line fix. If you need the update to be smaller, the next section has narrower options.

On the server, nothing changes except that it now gets a correct lockfile. Keep using `npm ci` there - it installs exactly what the lockfile says, which is now the right thing. [npm ci vs npm install](https://www.renode.space/blog/npm-ci-vs-npm-install) covers why the server should never be the machine that writes a lockfile. If you were tempted to switch the server to `npm install` to make the error go away, do not: with an existing broken lockfile it will often still skip the binary, and when it does not, it is because it rewrote your dependency tree on the server.

#### Narrower fixes when you cannot regenerate everything

Sometimes a full re-resolve is not acceptable - a release branch, an audit freeze, a monorepo where one lockfile covers forty packages. There are two narrower options, both with trade-offs.

**Declare the missing binary yourself.** Add the platform package to `optionalDependencies` in your own `package.json`, at exactly the same version as the `rollup` in your lockfile:

package.json:

```json
{
  "optionalDependencies": {
    "@rollup/rollup-linux-x64-gnu": "4.40.0"
  }
}
```

Then run `npm install` so the lockfile records it. Because it is optional, npm skips it on the Mac and installs it on Linux. The catch is the version: the binary has to match the Rollup release it belongs to, and the next time Vite pulls in a newer Rollup your pin is out of date. Treat it as a stopgap, and remove it once you have regenerated the lockfile properly.

**Use the WebAssembly build of Rollup.** Rollup publishes `@rollup/wasm-node`, the same bundler with the parser compiled to WebAssembly instead of native code. It has no platform packages at all, so this whole class of error disappears. Point every copy of `rollup` in the tree at it with an npm override:

package.json:

```json
{
  "overrides": {
    "rollup": "npm:@rollup/wasm-node"
  }
}
```

Run `npm install` after adding it. The price is speed - WebAssembly parsing is noticeably slower than native, which matters on large builds and barely registers on small ones. It is a reasonable choice for platforms Rollup has no native build for, and for teams that would rather have a slower build than a fragile one.

#### node_modules copied between machines, and Docker

The second cause has nothing to do with npm's bug. A `node_modules` folder installed on one platform contains that platform's binary and nothing else, so moving the folder to a different platform leaves the new machine without its binary. The lockfile can be perfect and this still happens.

The common ways it happens:

- **Uploading the project folder with `node_modules` inside**, by SFTP or as a zip. A folder installed on a Mac carries `darwin-arm64` and nothing for Linux.
- **Committing `node_modules` to Git.** It should be in `.gitignore`, always - it is large, it is platform-specific, and it is rebuilt from the lockfile in seconds.
- **Docker's `COPY . .` after `npm ci`.** This is the classic. The image installs the right Linux binary, and then the next line copies your laptop's `node_modules` over the top of it.

The Dockerfile pattern that avoids it copies only the manifest files first, installs inside the image, and copies the source afterwards:

Dockerfile:

```dockerfile
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
```

That alone is not enough, because `COPY . .` still copies a local `node_modules` if one exists. A `.dockerignore` file next to the Dockerfile stops it:

.dockerignore:

```text
node_modules
dist
.git
```

It also makes builds faster, since Docker no longer sends hundreds of megabytes of build context it was going to throw away.

Docker Compose development setups have their own version of this. A bind mount such as `.:/app` replaces the image's `/app` with your project folder, including your laptop's `node_modules`. The usual fix is an anonymous volume for the folder, which keeps the container's own copy:

compose.yaml:

```yaml
services:
  web:
    build: .
    volumes:
      - .:/app
      - /app/node_modules
```

[Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) and [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) cover the wider patterns.

#### Alpine, musl and ARM

Alpine-based images use musl instead of glibc, so Rollup asks for `linux-x64-musl`. With a complete lockfile and an install that runs inside the Alpine image, this just works: npm picks the musl build. It breaks when the install ran somewhere else - a `node_modules` built on Debian or on the CI runner and copied into an Alpine stage installs `linux-x64-gnu`, and Rollup inside Alpine will not load it. The rule is the same as for every other case: run `npm ci` in the same image, or at least the same base, as the one that runs the build.

If you have no strong reason to use Alpine, a Debian-based image such as `node:22-bookworm-slim` removes a whole category of native-module surprises, at the cost of a somewhat larger image.

ARM gets people through Apple Silicon. Docker on an M-series Mac builds `linux/arm64` images by default, so the install inside the build pulls `linux-arm64-gnu`. That image will not run on an x64 server for many reasons beyond Rollup. Build for the target explicitly:

```bash
$ docker build --platform linux/amd64 -t my-app .
```

A related failure looks similar but is not the npm bug. If the stack trace mentions something like `GLIBC_2.xx not found`, the binary was installed but the system's C library is too old to load it - typically an ancient build image. Upgrade the base image rather than reinstalling.

#### Do not switch optional dependencies off

The third cause is self-inflicted. `npm ci --omit=optional`, the older `--no-optional`, `omit=optional` in an `.npmrc`, or `NPM_CONFIG_OMIT=optional` in a CI environment all tell npm to skip every optional dependency - including the Rollup binary, which is optional only in the sense that npm should pick one. Rollup then fails exactly as if the bug had struck.

These settings tend to arrive from an old tutorial about shrinking images, or from a workaround for some other package's broken install script. Check what npm is actually using:

```bash
$ npm config get omit
```

It should print nothing, or `dev` if you deliberately skip dev dependencies. `optional` in that output is the problem. Note the separate trap: Vite and Rollup are devDependencies, so `npm ci --omit=dev` followed by `vite build` fails too, with `vite: not found` rather than this error. Install everything, build, then prune with `npm prune --omit=dev`.

#### On a hosted app server

On a container-based host the same rules apply, and the only real decision is where the build runs. On RE:NODE, Node.js app plans deploy from GitHub through the panel's Git deploy, and when your start command runs `npm ci` and the build, they run on the server - so a lockfile that lists every Rollup platform is the whole fix: commit it and deploy again. The panel console shows the install and build output unfiltered, so a missing binary shows up as the error above rather than as a silent crash - [reading the console](https://www.renode.space/blog/reading-the-console) covers what to look for. Do not upload a local `node_modules` through the file manager or SFTP; let `npm ci` build it on the server.

If the server keeps restarting because the build fails inside the start command, fix the lockfile before restarting again. A watcher counts crash restarts: three in an hour puts a warning on the server page and opens a ticket, six suspends the server. [Deploying a Node app from GitHub](https://www.renode.space/blog/deploy-a-node-app-from-github) walks through the start command and the build-then-prune sequence. If your Vite project builds to plain static files, you do not need Node at runtime at all - build it in CI and serve the `dist` folder, which [static site hosting](https://www.renode.space/blog/static-site-hosting-guide) covers.

#### FAQ

##### Why does the build work on my laptop but not on the server?

Your laptop has the binary for its own platform, installed when you first ran `npm install`. The server needs a different one, and either the lockfile does not list it or something stopped npm installing it. The `grep -c` check above tells you which.

##### Will deleting node_modules on the server fix it?

Not if the lockfile is the problem. `npm ci` deletes `node_modules` anyway and reinstalls from the lockfile, so a lockfile without the Linux binary produces the same result every time. The lockfile has to be regenerated and committed.

##### Should I install @rollup/rollup-linux-x64-gnu as a normal dependency?

No. A normal dependency is not optional, so the install fails outright on every Mac and Windows machine with an `EBADPLATFORM` error. If you need a stopgap, put it in `optionalDependencies` at Rollup's exact version.

##### Does using pnpm or Yarn avoid this?

The specific lockfile bug is npm's, so switching package manager often makes it go away. Copying `node_modules` between platforms and disabling optional dependencies break every package manager equally, so the Docker and `.dockerignore` advice still applies.

##### Is the same thing behind esbuild and SWC platform errors?

Yes, the mechanism is identical: one optional package per platform. esbuild's message is different - it says you installed esbuild for another platform than the one you are using - but the fixes are the same: a complete lockfile, an install on the target platform, and no copied `node_modules`.

Node.js app plans deploy from GitHub through a GitHub App with short-lived tokens, run whatever install and build steps your start command contains, and show the output unfiltered in the console. Backup slots on every plan mean a dependency update that goes wrong is one restore away.

[See Node.js hosting](https://www.renode.space/app-hosting)

---

### Discord webhooks explained: create, send, edit, secure

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/discord-webhooks-explained

A Discord webhook is a URL that posts messages into one channel. You create it in the channel's settings, you send it an HTTP `POST` with a small JSON body, and a message appears under whatever name and avatar you chose. There is no bot account, no token to register, no library to install and no process to keep running. That makes it the right tool for one-way messages from software - a deploy finished, a form was submitted, a backup failed, a new video went up - and the wrong tool for anything that has to listen or reply.

Everything below works from any language and any machine that can make an HTTPS request. The parts people get wrong come later: the URL is the only credential and it never expires, a few query parameters (`wait`, `thread_id`) change the behaviour completely, and a webhook that is rate limited tells you exactly how long to wait if you read the response. If what you want is a live status message for a game server, [Discord webhooks for game server alerts](https://www.renode.space/blog/discord-webhooks-for-server-status) covers that use case end to end; this post is the general reference it builds on.

#### What a webhook is, and the URL that is the whole thing

A webhook belongs to a channel. It has a numeric id and a long random token, and the URL is simply both of them joined:

```text
https://discord.com/api/webhooks/1290000000000000000/Xy7k...the-token...Qp2
```

Anything that can send that URL a request with the right body can post to the channel. There is no second factor, no login, no scope beyond the one channel and no expiry. The URL is the password, and it stays valid until someone deletes the webhook. That single fact drives most of the security advice at the end of this post.

A message posted through a webhook shows the webhook's name and avatar (or the ones you override per message), plus a small tag next to the name that marks it as automated. It is not a member of the server, it does not appear in the member list, and it has no roles. It cannot be mentioned, cannot receive a direct message and cannot see anything in the channel - including the messages it posted itself, except through the specific endpoints that fetch, edit or delete them.

Webhooks also turn up in two places you might not expect:

- **Following an announcement channel.** When a server follows another server's announcement channel, Discord creates a webhook in the destination channel to deliver the cross-posts. It shows up in the integrations list like any other.
- **Integrations that post on your behalf.** Many services (CI systems, monitoring tools, GitHub itself) ask you for a Discord webhook URL rather than asking you to install a bot, precisely because a webhook needs no permissions beyond one channel.

#### Creating a webhook in channel settings

You need the **Manage Webhooks** permission in that channel, which server owners and administrators have by default.

1. Hover over the channel and click the gear icon (Edit Channel), or right-click the channel and choose Edit Channel.
2. Open **Integrations**, then **Webhooks**.
3. Click **New Webhook**. Discord creates one immediately with a generated name.
4. Click it to expand, set a name and optionally upload an avatar, and check the channel dropdown is the one you want.
5. Click **Copy Webhook URL**, then **Save Changes** if you changed anything.

Server Settings, Integrations, Webhooks shows the same thing for the whole server: every webhook in every channel, with who created it. That page is where you go to audit or clean up, and the server's audit log records webhook creation, changes and deletion.

A few practical limits apply at the time of writing: a channel holds up to 15 webhooks, a webhook name must be between 1 and 80 characters, and names containing the words `discord` or `clyde` are rejected. Discord adjusts these occasionally, so if creation fails with an error about limits, the developer documentation is the place to check.

Name the webhook after its source - `CI`, `Backups`, `Shop orders` - rather than leaving "Captain Hook" or whatever was generated. The name is the default author of every message and the label you will be scanning for in the integrations list when something misbehaves.

> [!TIP]
> Create one webhook per sender rather than one webhook shared by five scripts. When a URL leaks or a script starts spamming, you delete one webhook and nothing else stops working.

#### Sending your first message with curl

The smallest valid request is a `POST` with a JSON body that has `content`:

```bash
$ curl -X POST "$WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -d '{"content": "Deploy finished on main."}'
```

On success Discord answers `204 No Content` with an empty body. That is not an error and it is not empty-handed by accident: by default the API does not wait for the message to be stored before answering. Add `?wait=true` and you get `200 OK` with the full message object back, including its `id`, which you need to edit or delete it later.

The fields you will use on almost every message:

| Field | What it does |
| --- | --- |
| `content` | Message text, up to 2000 characters, Discord markdown |
| `username` | Overrides the webhook's name for this message only |
| `avatar_url` | Overrides the avatar, a direct link to an image |
| `embeds` | Up to 10 embed objects (cards with title and fields) |
| `allowed_mentions` | Controls which mentions actually ping |
| `flags` | `4096` sends silently, without notifications |
| `tts` | Reads the message aloud to people with TTS enabled |

At least one of `content`, `embeds`, a file or a poll must be present, or the request fails with `400`. A full example with an overridden identity and mentions switched off:

```bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \
    -H "Content-Type: application/json" \
    -d '{
      "username": "Shop",
      "avatar_url": "https://example.com/shop-icon.png",
      "content": "New order #1042 - 2 items, paid by card",
      "allowed_mentions": {"parse": []}
    }'
```

`avatar_url` has to point straight at an image file that Discord can fetch - a page that contains the image will not do, and a link that needs a login will silently fall back to the default avatar. `username` and `avatar_url` apply only to that one message; the webhook's stored name and avatar are unchanged.

Content takes ordinary Discord markdown: `**bold**`, inline code, code blocks, masked links like `[changelog](https://example.com)`, user mentions as `<@USER_ID>`, role mentions as `<@&ROLE_ID>`, and timestamps such as `<t:1760000000:R>` that render as "3 hours ago" in each reader's own timezone. Whether a mention actually pings is decided by `allowed_mentions`. An empty `parse` array, as above, means nothing pings at all, which is the safe default for anything that forwards text you did not write yourself. To ping one specific role and nothing else, list it explicitly:

```json
{
  "content": "<@&123456789012345678> the nightly build failed",
  "allowed_mentions": { "parse": [], "roles": ["123456789012345678"] }
}
```

When something is wrong, Discord replies `400` with a JSON body naming the field it rejected. Read it before changing anything else - it is nearly always more specific than you expect.

#### Sending from Python and JavaScript

There is nothing in a webhook that needs a Discord library. Any HTTP client will do, and the code is short enough that a dependency is optional.

Python with `requests`:

```python
import os
import requests

url = os.environ["WEBHOOK_URL"]
payload = {
    "username": "Backups",
    "content": "Nightly backup finished: 412 MB in 38 s",
    "allowed_mentions": {"parse": []},
}
r = requests.post(url, json=payload, params={"wait": "true"}, timeout=10)
r.raise_for_status()
print("message id:", r.json()["id"])
```

If the project already uses discord.py 2.x, it has a synchronous webhook client that needs no bot and no event loop:

```python
from discord import SyncWebhook

hook = SyncWebhook.from_url(os.environ["WEBHOOK_URL"])
msg = hook.send("Deploy finished", username="CI", wait=True)
print(msg.id)
```

JavaScript on Node 18 or later has `fetch` built in, so the plain version needs no packages:

```js
const res = await fetch(`${process.env.WEBHOOK_URL}?wait=true`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    username: "CI",
    content: "Build 512 passed",
    allowed_mentions: { parse: [] },
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const message = await res.json();
console.log(message.id);
```

discord.js has the equivalent in `WebhookClient`, created with `new WebhookClient({ url })` and used with `send()`. It handles rate limits for you, which is worth having if the script sends a lot.

In every language, keep the URL in an environment variable and never in source. The rest of the code is the same three steps: build a dictionary, send it as JSON, check the status code. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers where the value should live on a server.

#### Embeds, files and the limits that return 400

Plain `content` is right for one-line notices. For anything with structure - an order with several fields, a build with a commit and an author, a report with a colour that means good or bad - use an embed:

```json
{
  "username": "CI",
  "embeds": [{
    "title": "Build 512 passed",
    "url": "https://example.com/builds/512",
    "description": "main - fix login redirect",
    "color": 3066993,
    "fields": [
      { "name": "Duration", "value": "2m 14s", "inline": true },
      { "name": "Tests", "value": "418 passed", "inline": true }
    ],
    "footer": { "text": "runner-02" },
    "timestamp": "2026-10-15T09:30:00.000Z"
  }]
}
```

Two details catch nearly everyone. `color` is a decimal integer, so the hex colour `#2ECC71` is sent as `3066993`; a string there is a `400`. And `timestamp` must be ISO 8601, which Discord then shows in each reader's local time.

Limits are enforced, not truncated: 2000 characters of `content`, 10 embeds per message, 256 characters of embed title, 4096 of description, 25 fields with 256-character names and 1024-character values, and 6000 characters across all embeds in one message. The server status post has [the full limits table](https://www.renode.space/blog/discord-webhooks-for-server-status) and what happens when a stack trace blows past it. Truncate on your side before sending.

Files go up as `multipart/form-data` rather than JSON. The JSON body moves into a form field called `payload_json`, and each file is a field named `files[0]`, `files[1]` and so on:

```bash
$ curl -X POST "$WEBHOOK_URL" \
    -F 'payload_json={"content": "Error log from the failed run"}' \
    -F 'files[0]=@build.log'
```

The upload size limit depends on the server's boost level and Discord has changed it more than once; on an unboosted server it is currently 10 MB per message. Logs bigger than that belong in object storage or a log system, with a link in the message.

Two compatibility endpoints save writing an adapter. Appending `/slack` to the webhook URL accepts a Slack-format payload, and `/github` accepts GitHub's webhook format, so you can paste `https://discord.com/api/webhooks/ID/TOKEN/github` into a repository's webhook settings (content type `application/json`) and get commit and pull request notices with no code.

#### wait=true, threads and forum channels

The query string on the execute URL changes behaviour, and two parameters do most of the work.

| Parameter | Effect |
| --- | --- |
| `wait=true` | Answer `200` with the stored message, not `204` |
| `thread_id=ID` | Post into an existing thread in the webhook's channel |

Use `wait=true` whenever you will touch the message again, or whenever you want to be certain it was actually stored before your script moves on. Without it, a `204` tells you the request was accepted and nothing more.

`thread_id` lets one webhook feed many threads. The thread has to belong to the webhook's channel - a webhook in `#builds` can post into any thread under `#builds`, not into a thread elsewhere. A common pattern is one thread per release or per incident, with every related message posted into it, so the parent channel stays readable.

Forum and media channels have no plain message area, only posts, so a webhook there has to say which post it means. Either pass `thread_id` to reply in an existing post, or send `thread_name` in the body to create a new post with your message as its first message:

```bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \
    -H "Content-Type: application/json" \
    -d '{"thread_name": "Incident 2026-10-15", "content": "API returning 502 since 09:12"}'
```

The response to that request contains the message, and its `channel_id` is the id of the new thread - keep it if you will post follow-ups. A request to a forum webhook with neither `thread_id` nor `thread_name` fails with `400`. If the forum requires tags, `applied_tags` takes a list of tag ids.

#### Editing, deleting and managing messages

A webhook can only change messages it sent itself, and it addresses them by id under its own URL:

```bash
# Read a message the webhook sent
$ curl "$WEBHOOK_URL/messages/$MESSAGE_ID"

# Edit it - only the fields you send are changed
$ curl -X PATCH "$WEBHOOK_URL/messages/$MESSAGE_ID" \
    -H "Content-Type: application/json" \
    -d '{"content": "Deploy finished on main (rolled back 09:40)"}'

# Delete it
$ curl -X DELETE "$WEBHOOK_URL/messages/$MESSAGE_ID"
```

If the message lives in a thread, add `?thread_id=THREAD_ID` to each of those URLs as well, or Discord looks for it in the parent channel and returns `404`. An edit leaves the original author name and avatar as they were; `username` and `avatar_url` are not accepted on edit. To clear embeds, send `"embeds": []`; to clear text, send `"content": ""` (the message still needs something left in it).

Edits do not notify anyone. That makes edit-in-place the right shape for anything that changes repeatedly - a progress line, a queue length, a live status card - and a new post the right shape for events people should notice. The [server status post](https://www.renode.space/blog/discord-webhooks-for-server-status) builds a small state machine around exactly this.

The webhook itself is managed through the same URL:

- `GET $WEBHOOK_URL` returns the webhook object - its name, channel id, server id and avatar hash. When fetched with the token alone it leaves out the user who created it. It is a cheap way to check that a stored URL still works.
- `PATCH $WEBHOOK_URL` with `name` and/or `avatar` changes the stored identity. `avatar` is a data URI (`data:image/png;base64,...`), not a link. Moving a webhook to another channel needs a bot or user token, not the webhook token.
- `DELETE $WEBHOOK_URL` deletes the webhook. Every request to the URL then returns `404`, permanently.

That last one matters: anybody holding the URL can delete the webhook, which is useful when you find a leaked one in someone else's paste and annoying when a careless script runs it by mistake.

#### Rate limits and the 429 response

Discord rate-limits every route, webhooks included. The practical ceiling for one webhook is in the region of five requests every two seconds, and a channel takes roughly thirty messages a minute regardless of how many webhooks post to it. Those figures are not published as fixed numbers and they move, so do not hard-code a sleep and consider it handled. Read the response.

When you go over, the reply is `429 Too Many Requests` with headers and a body:

```json
{ "message": "You are being rate limited.", "retry_after": 0.53, "global": false }
```

`retry_after` is in seconds and may be fractional. Wait that long, then retry the same request once. The headers `X-RateLimit-Remaining` and `X-RateLimit-Reset-After` let a careful client slow down before it ever hits the wall. A minimal Python retry:

```python
import time, requests

def send(url, payload, attempts=5):
    for _ in range(attempts):
        r = requests.post(url, json=payload, timeout=10)
        if r.status_code != 429:
            r.raise_for_status()
            return r
        time.sleep(float(r.json().get("retry_after", 1)))
    raise RuntimeError("still rate limited")
```

What gets webhooks into trouble is not the occasional `429` but the reaction to it. A script that retries instantly in a loop turns a half-second wait into a flood, and repeated invalid requests (`401`, `403` and `429` responses) can earn the sending IP a temporary block from Discord's API as a whole. Batch where you can - ten log lines joined with newlines are one request - and back off on `5xx` errors too, which Discord returns under load.

#### A leaked webhook URL: what to do

Webhook URLs leak in boring ways: committed in a config file, pasted into a support chat, visible in a screenshot, printed in a CI log, embedded in client-side JavaScript of a website. The last one is the most common and the most avoidable - any URL that reaches a browser is public, and there are scrapers that look for exactly this pattern and use it to post spam or phishing links under your server's banner.

When one leaks, the clean-up is short and should be done in this order:

1. **Delete the webhook.** Channel settings, Integrations, Webhooks, select it, Delete Webhook. Or `curl -X DELETE "$WEBHOOK_URL"` from anywhere. The old URL stops working at once. There is no "reset token" option; deleting and recreating is the rotation.
2. **Create a new one** in the same channel and put the new URL into the environment variable or secret store your sender reads. Restart the sender so it picks it up.
3. **Clean the channel.** Delete anything the attacker posted. Messages already sent stay after the webhook is gone, and with no webhook left you delete them as a moderator, by hand.
4. **Remove the source.** Rewrite the file, revoke the paste, rotate the CI log. If it was in a Git repository, deleting it in a new commit is not enough - it is still in history, and the leaked URL is dead anyway, so focus on making sure the new one never goes the same way.
5. **Audit the rest.** Server Settings, Integrations, Webhooks lists every webhook in the server. Delete any that nobody can account for.

> [!WARNING]
> A webhook URL must never be shipped to a browser or a game client. If a website's contact form needs to post to Discord, the form posts to your own server, and your server posts to the webhook. That backend also gets to validate input and apply its own rate limit, which a bare webhook cannot.

Treat the URL like a database password: an environment variable on the server that sends, redacted in screenshots and logs, and one per sender so that one leak is one deletion.

#### Webhook or bot: picking the right one

The decision is about direction. A webhook writes into a channel. A bot connects to Discord's gateway, receives events and can act on them.

| You want to | Use |
| --- | --- |
| Post notices from a script, CI or a website | Webhook |
| Edit or delete those notices later | Webhook |
| Post under several names and avatars | Webhook |
| Respond to slash commands or buttons | Bot |
| Read chat, reactions or member joins | Bot |
| Assign roles, moderate, send DMs | Bot |
| Post in many servers you do not own | Bot, invited to each |

A webhook needs nothing running between messages, so it costs nothing to keep. A bot is a long-running process with a token, intents and a gateway connection that must stay up; if it stops, it shows as offline and its commands fail. That is a hosting problem in its own right, covered in [hosting a Discord bot 24/7](https://www.renode.space/blog/host-a-discord-bot-24-7) for discord.js and [hosting a discord.py bot](https://www.renode.space/blog/discord-py-bot-hosting-guide) for Python.

Two hybrids are worth knowing. A bot can create webhooks itself and use them to post under other identities - which is how chat bridges show in-game players with their own names and avatars. And a webhook created by an application (rather than by a person in channel settings) can send interactive components such as buttons, because the interactions go back to that application. A webhook you make in the channel settings cannot receive anything, so buttons on it would have nowhere to report.

There is also a separate feature Discord calls webhook events: an application can register an HTTPS endpoint in the developer portal and receive certain events, such as the app being authorised, as incoming requests. That is the reverse direction - Discord calling you - and it is for application developers, not a way to read a channel.

Where the sender runs is the last decision. A webhook only needs something that can make a request when the event happens: a CI job, a cron task, a backend that already exists. If it does not exist yet, a small always-on app server is enough. RE:NODE app plans for Node.js and Python start at $4 a month, with the webhook URL kept as an environment variable on the Startup tab and deploys straight from GitHub.

#### FAQ

##### Do I need to code to use a Discord webhook?

No. Many services take a webhook URL and do the sending for you - paste it in and they post. To send your own messages, a single `curl` command is enough, and there are also web tools that build the JSON for you. Code is only needed when your own program decides what and when to post.

##### Why does my webhook return 204 and no message id?

Because `204 No Content` is the default success reply. Add `?wait=true` to the URL and Discord returns `200` with the full message, including the `id` you need to edit or delete it.

##### Can a webhook post to several channels?

No. Each webhook belongs to one channel, and it can only reach threads inside that channel through `thread_id`. For several channels, create one webhook in each, which is better for leaks and noise anyway.

##### Can I make a webhook ping a role?

Yes. Put `<@&ROLE_ID>` in `content` and include the id in `allowed_mentions.roles`, keeping `parse` empty so nothing else pings. The role id comes from right-clicking the role with Developer Mode switched on in Discord's Advanced settings, which is also how you copy user, channel and message ids.

##### Do webhook URLs expire?

No. A webhook works until it is deleted, its channel is deleted, or the server removes it. A URL that worked last year and returns `404` today was deleted by someone - check Server Settings, Integrations, Webhooks.

##### Why does the webhook reject my username?

Names must be 1 to 80 characters and cannot contain `discord` or `clyde`. The `400` response body says which rule it hit; shorten the name or drop the word.

If the thing that sends your webhooks needs a home, Node.js and Python plans on RE:NODE start at $4 a month, with environment variables on the Startup tab for the webhook URL, deploy from GitHub, and an unfiltered console to watch the replies come back.

[See app hosting plans](https://www.renode.space/app-hosting)

---

### Docker healthcheck interval, start period and retries

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/docker-healthcheck-interval-and-start-period

A Docker health check runs its first probe one full `interval` after the container starts, not straight away. With the defaults that is 30 seconds of `health: starting`. With `--interval=1h` it is an hour, and anything waiting on `service_healthy` waits that hour too. The fix is not a shorter interval for the whole life of the container. Set a `start_period` long enough for the app to boot, and on Docker Engine 25.0 or later add a short `start_interval`, so the check polls quickly while the app starts and slowly once it is up. The other thing people get wrong: a container marked `unhealthy` keeps running. Plain Docker does not restart it, and neither does Compose.

This post covers the timing in detail: each of the five options, how they combine into "how long until healthy" and "how long until unhealthy", the Dockerfile and Compose syntax side by side, writing a check for an image that has no `curl`, and reading the result. For what the endpoint itself should check, and why liveness and readiness are different questions, see [graceful shutdown and real health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks). For how a health check fits into a full stack with a proxy and a database, see [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks).

#### The five timing options and their defaults

A health check is a command Docker runs inside the container on a timer. Exit code `0` means healthy, `1` means unhealthy, and `2` is reserved and should not be used. In practice Docker treats any non-zero exit as a failure, but the documentation asks for `1`, so the usual idiom ends a check with `|| exit 1`.

| Option | Default | What it controls |
| --- | --- | --- |
| `--interval` | `30s` | Time between checks once running |
| `--timeout` | `30s` | How long one check may take before it counts as failed |
| `--retries` | `3` | Consecutive failures needed to mark it unhealthy |
| `--start-period` | `0s` | Grace window after start where failures do not count |
| `--start-interval` | `5s` | Time between checks during the start period (Engine 25.0+) |

A few details the table leaves out:

- **The interval counts from when the last check finished**, not from when it started. A check that takes 4 seconds with a 30-second interval runs about every 34 seconds.
- **The timeout is a hard limit on one run.** If the command is still going when it expires, that run is a failure. The default of 30 seconds is far too generous for a local HTTP request. If `/healthz` has not answered in 3 to 5 seconds, something is wrong, and waiting longer only delays finding out.
- **Retries counts consecutive failures.** One success resets the counter. A check that fails every other time never reaches `unhealthy`, which is a good reason to make the check deterministic.
- **The start period does not delay the first check.** Checks still run during it. Their failures just do not count. A success during the start period ends it early: the container is healthy, and every later failure counts.
- **The start interval only applies inside the start period.** With `start_period: 0s`, the default, `start_interval` does nothing.

#### Why the first check waits a whole interval

This is the behaviour behind most "my container is stuck in starting" questions. Docker schedules the first probe one interval after the container starts. It does not run one at time zero to see if the app is already up. Until that first probe succeeds, `docker ps` shows `(health: starting)`.

That is harmless with the 30-second default. It stops being harmless when someone reasons: "the check is cheap, but I don't need it often, so `interval: 5m`". Now every fresh container sits in `starting` for five minutes. Every service with `depends_on: condition: service_healthy` waits five minutes before Compose even creates it. And `docker compose up --wait` blocks for five minutes on a deploy that was ready in two seconds. At `interval: 1h` the container is `starting` for an hour.

Before Engine 25.0 there was no clean way out. A long interval meant a slow first verdict, and a short interval meant polling every few seconds forever. People compromised on 10 to 30 seconds and accepted both costs.

`--start-interval` separates the two. During the start period Docker checks every `start_interval`. After the first success, or once the start period ends, it switches to the normal `interval`. A container can be checked every 2 seconds while it boots and every 5 minutes after that:

Dockerfile:

```dockerfile
HEALTHCHECK --interval=5m --timeout=3s --retries=3 \
  --start-period=60s --start-interval=2s \
  CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
```

If the app answers after 7 seconds, the fourth or fifth probe succeeds and the container is `healthy` about 8 seconds after start, not 5 minutes. If the app never comes up, the start period runs out after 60 seconds, and from then on failures count at the 5-minute interval.

> [!WARNING] On an engine older than 25.0, `--start-interval` is not honoured, and the first check falls back to one full `interval` after start. If you rely on it to keep a long interval workable, check `docker version` on every host that runs the image, CI runners included. A long interval on an old engine quietly brings the hour of `starting` back.

#### How long until healthy, how long until unhealthy

The two numbers worth working out for any check are how fast a good container is declared healthy, and how slowly a dead one is declared unhealthy.

Time to healthy is roughly the boot time, rounded up to the next probe. Without a start interval, the first probe is at `interval`, so a container is never healthy sooner than that. With one, it is boot time plus at most one `start_interval`.

Time to unhealthy for an app that hangs after it was healthy is about `retries` checks:

```text
worst case ~ retries x (interval + timeout)
defaults:    3 x (30s + 30s) = about 3 minutes
tuned:       3 x (10s + 3s)  = about 40 seconds
```

The timeout counts because a hung app does not refuse the connection. It accepts it and never answers, so every probe runs to the full timeout before it fails. That is the second reason to cut the default timeout: with defaults, a hung process can serve errors for three minutes before Docker admits anything is wrong.

For an app that never becomes healthy at all, add the start period. Failures inside it do not count, so the first counted failure lands after `start_period`, and `unhealthy` follows `retries` probes later.

Some starting points, to adjust for your own app:

| Workload | Interval / timeout | Start period |
| --- | --- | --- |
| Small web app or API | `30s` / `3s` | `30s`, start interval `2s` |
| PostgreSQL or MySQL | `10s` / `5s` | `30s` to `60s` |
| JVM or .NET app | `30s` / `5s` | `60s` to `120s` |
| Database restoring a dump on first run | `10s` / `5s` | Several minutes |

Keep `retries` at 3 unless you have a reason. One failure is noise. Three in a row is a pattern.

#### Dockerfile syntax vs Compose syntax

The same check can live in three places, and the spelling differs in each.

In a **Dockerfile** it is an instruction with flags, and it becomes part of the image metadata:

Dockerfile:

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --start-interval=2s \
  --retries=3 CMD ["curl", "-fsS", "http://127.0.0.1:8080/healthz"]
```

A Dockerfile can only have one `HEALTHCHECK`. If there are several, the last one wins. A health check in a base image is inherited, and `HEALTHCHECK NONE` switches it off.

In **Compose** it is a `healthcheck:` key on the service, with underscores where the flags have hyphens:

compose.yaml:

```yaml
services:
  app:
    image: example/app:1.4.2
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 30s
      start_interval: 2s
```

Durations are written like `1m30s`, `10s` or `500ms`. A Compose `healthcheck` overrides the one baked into the image field by field, so you can keep the image's command and change only the timing. To remove an image's check entirely, use `disable: true` (or `test: ["NONE"]`). `start_interval` in a Compose file needs Docker Compose 2.20.2 or later and, like the Dockerfile flag, Engine 25.0 or later to take effect.

On **`docker run`** the same options are flags: `--health-cmd`, `--health-interval`, `--health-timeout`, `--health-retries`, `--health-start-period`, `--health-start-interval`, and `--no-healthcheck` to disable an inherited one.

Which place is right? Put the check in the Dockerfile when it belongs to the app: the image knows its own port and health path, and every deployment gets a sensible default. Put it in Compose when it depends on the deployment, such as a different port, a dependency the check should wait for, or a third-party image you cannot rebuild.

#### CMD vs CMD-SHELL, and the dollar sign trap

The `test` field takes three forms, and the difference matters as soon as the command contains a pipe, a variable or `||`.

- `["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]` - exec form. Docker runs the binary directly, with no shell. No pipes, no `$VAR` expansion, no `||`. It also works in images that have no shell.
- `["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz || exit 1"]` - runs the string through the container's default shell, `/bin/sh -c` on Linux. Pipes, variables and `||` work.
- `test: curl -fsS http://127.0.0.1:8080/healthz || exit 1` - a plain string is the same as `CMD-SHELL`.

In a Dockerfile the same split exists: `HEALTHCHECK CMD ["curl", ...]` is exec form, and `HEALTHCHECK CMD curl ... || exit 1` is shell form.

The trap is variables in Compose. Compose interpolates `$VAR` itself, from your shell and `.env` file, before the container ever sees it. A check written as `pg_isready -U $POSTGRES_USER` gets the value from the machine running `docker compose`, which is often empty. To make the container's shell expand it, double the dollar sign:

compose.yaml:

```yaml
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 2s
```

`$$` reaches the container as a single `$`. The container's environment does contain `POSTGRES_USER`, because health checks run with the container's environment, just like `docker exec`.

Two more things about where the check runs. It runs inside the container, so `localhost` is the container itself, not the host. That is correct for checking the app, but it means a health check never tests a published port or a proxy in front. And prefer `127.0.0.1` over `localhost`: on some images `localhost` resolves to the IPv6 `::1` first, an app bound to `0.0.0.0` (IPv4 only) refuses the connection, and the check fails even though the app is fine.

#### When the image has no curl or wget

The examples above assume `curl`, and plenty of images do not ship it. Slim and minimal images leave out everything the app does not need: `node:22-slim` and `python:3.13-slim` have neither `curl` nor `wget`. Alpine-based images have BusyBox `wget` but no `curl`. Distroless images have no shell either, so any `CMD-SHELL` check fails before it starts. A check that cannot find its binary fails every time, and the container goes `unhealthy` while the app is perfectly fine. `docker inspect` then shows `curl: not found` or an `exec` error in the health log.

There are three ways around it.

1. **Install the tool.** Adding `curl` to a Debian slim image is one `apt-get install` line and a few megabytes. That is often fine, but it adds a package to the image's attack surface just for a probe.
2. **Use what is already there.** On Alpine, BusyBox `wget` works: `wget -q --spider http://127.0.0.1:8080/healthz || exit 1`. Databases ship their own probes: `pg_isready` in the Postgres image, `mysqladmin ping` in MySQL.
3. **Use the runtime you already have.** A Node image has Node, and a Python image has Python. Both can make an HTTP request in one line.

Dockerfile (Node 18 or later):

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --start-interval=2s \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
```

Dockerfile (Python):

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --start-interval=2s \
  CMD ["python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
```

The Python version works because `urlopen` raises on any 4xx or 5xx status and on a refused connection, and an uncaught exception exits with status `1`. Both are exec form, so they also work on images with no shell. Starting a Node or Python interpreter costs more than `curl` does, tens of megabytes for a moment. That is irrelevant at a 30-second interval and worth a thought at a 2-second start interval on a small machine.

For distroless images the cleanest answer is a tiny health-check binary built into the image, or a `--health` mode in your own app that makes the request and exits. Many Go services do exactly that.

Whichever tool you use, make it fail on an HTTP error, not just on a refused connection. Plain `curl` exits `0` on a `500` response. `-f` (`--fail`) is what turns an error status into a non-zero exit. Without it the check only proves something is listening.

#### depends_on and service_healthy

Health status matters most to Compose's start ordering. A dependency declared with `condition: service_healthy` holds back the dependent service until the dependency reports `healthy`:

compose.yaml:

```yaml
  app:
    image: example/app:1.4.2
    depends_on:
      db:
        condition: service_healthy
        restart: true
```

This is where interval tuning pays off. Compose waits for the database's first successful probe, so a database with `interval: 60s` and no start interval delays the app by at least a minute on every `up`, however fast Postgres actually starts. With `start_interval: 2s` the app starts within a couple of seconds of the database being ready.

Compose also gives up if a dependency goes `unhealthy` instead: `up` fails with a message that the dependency failed to start. Usually the start period was too short for a first run, such as a database initialising a fresh volume or restoring a dump, while later restarts were fine. Lengthen the start period rather than raising `retries`, so the grace applies only at boot.

`restart: true` (Compose 2.17.0 or later) restarts the dependent service when Compose itself restarts or updates the dependency. It is not a health-based restart. A database that goes unhealthy on its own triggers nothing.

`docker compose up --wait` uses the same signal for deploys: it returns once every service is running and healthy, and exits non-zero if one fails. In a deploy script, that is what lets you stop before the traffic switch rather than after. [Zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) builds on exactly that wait.

#### Reading the health log with docker inspect

`docker ps` shows the summary in the status column: `Up 2 minutes (healthy)`, `(health: starting)` or `(unhealthy)`. To find every unhealthy container on a host:

```bash
$ docker ps --filter health=unhealthy
$ docker inspect --format '{{json .State.Health}}' app | python3 -m json.tool
```

The `Health` object holds `Status`, `FailingStreak` (consecutive failures so far) and `Log`, a list of the most recent probe results. Each entry has a `Start`, an `End`, an `ExitCode` and an `Output`. Docker keeps only the last five results and stores only the first 4096 bytes of each check's output. So a health endpoint that prints a short reason on failure (`db: connection refused`) is far more useful here than one that dumps a stack trace.

Read the `Output` before changing anything. The common messages and what they mean:

- `curl: not found`, `executable file not found in $PATH` - the tool is missing from the image. See the section above.
- `Health check exceeded timeout` - the command did not finish in time. Either the app is hung or the timeout is shorter than a legitimate slow response.
- `Connection refused` - nothing is listening on that address inside the container. Check the port, and check `127.0.0.1` against `localhost`.
- An HTTP `503` from your own endpoint - the check is working and the app is reporting a real problem. Fix the app, not the check.

You can also watch transitions as they happen with `docker events --filter event=health_status`, which prints a line each time a container changes between `healthy` and `unhealthy`.

#### Unhealthy does not restart anything

Docker's restart policies (`no`, `on-failure`, `always`, `unless-stopped`) react to the container's main process exiting, and nothing else. An `unhealthy` container whose process is still running stays exactly as it is, still receiving traffic if it was receiving traffic before. The health status is information. Plain Docker and Compose report it and act on it only at start-up, through `depends_on` and `--wait`.

Three things do act on it:

- **Docker Swarm** replaces service tasks that become unhealthy. If you run Swarm, the health check is a real restart trigger.
- **A watcher you add.** A small container with access to the Docker socket polls for `unhealthy` and restarts what it finds. The `willfarrell/autoheal` image is the well-known example. Mounting the Docker socket gives that container root-equivalent control of the host, so treat it as the trade-off it is.
- **The app exiting on its own.** If the app can detect that it is broken, exiting with a non-zero status is the most reliable restart trigger there is, because every restart policy understands it.

Kubernetes is the exception people trip over: it ignores the Dockerfile `HEALTHCHECK` completely and uses its own liveness, readiness and startup probes. `startupProbe` plays the role of the start period there.

Be careful what you wish for with automatic restarts. A check that fails because the database is down will restart a healthy app in a loop, which the [graceful shutdown post](https://www.renode.space/blog/graceful-shutdown-and-health-checks) calls the amplifier. Whatever restarts on health should only look at things a restart can fix. And alert on the health status changing, as [monitoring that tells you something](https://www.renode.space/blog/monitoring-that-tells-you-something) describes, rather than silently papering over it.

On RE:NODE app plans the question looks a little different. Each server is a container we run, without a Docker daemon of yours inside, so there is no `HEALTHCHECK` to tune. Instead, a crash watcher polls every two minutes for uptime that went backwards or a server that went offline, and restarts you asked for are not counted. Three unplanned restarts in an hour put a warning on the server page and open a ticket. Six suspend the server. Exiting on a fatal error is therefore still the right design there. If you want Docker's own health machinery with Compose and `service_healthy`, that needs a machine where the daemon is yours, which is what the [VDS line](https://www.renode.space/dedicated-servers) is for. [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) covers the setup.

#### FAQ

##### Why is my container stuck in "health: starting"?

The first probe has not succeeded yet. Either the first check only happens after one full `interval` (so a long interval means a long wait), or every check so far has failed inside the start period, where failures do not count. Run `docker inspect --format '{{json .State.Health}}'` on the container. An empty `Log` means no check has run yet. Entries with non-zero exit codes mean the check is failing and the `Output` says why.

##### What is a good Docker healthcheck interval?

For a typical web service, 30 seconds with a 3-second timeout and 3 retries, which flags a hung app in under two minutes. Go shorter only if something acts on the result quickly, such as a load balancer or Swarm. On Engine 25.0 or later, pair any interval longer than a minute with a `start_period` and a `start_interval` of a few seconds, so start-up does not wait for the long interval.

##### Does start_period delay the first health check?

No. Checks run during the start period at the normal `interval`, or at `start_interval` on Engine 25.0 and later. Failures inside the window are simply not counted toward `retries`. The first success ends the start period early.

##### Does Docker restart an unhealthy container?

Not on its own. Restart policies fire only when the main process exits. Swarm services, a watcher such as autoheal, or the app exiting by itself are the ways to turn `unhealthy` into a restart. Compose uses health only to order start-up and for `up --wait`.

##### Should the health check call the database?

Usually not for a check that anything restarts on. A database blip would then mark every app container unhealthy at once. Check the process itself, and report dependencies separately through a readiness endpoint. For a database container, the check should of course be the database's own probe, such as `pg_isready` or `mysqladmin ping`.

##### Can I change a health check without rebuilding the image?

Yes. A `healthcheck:` block in Compose or the `--health-*` flags on `docker run` override the image's values, and `disable: true` or `--no-healthcheck` turns it off. The Dockerfile instruction is only the default.

Health checks, `service_healthy` and `compose up --wait` need a Docker daemon you control. The RE:NODE VDS line is an Intel i9-9900K in Germany with NVMe on a ZFS pool and full root access, from 8 GB and two vCPU. Each one is prepared by hand and arrives within 24 hours.

[See the VDS line](https://www.renode.space/dedicated-servers)

---

### PM2 guide: ecosystem config, startup, logs and reload

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/pm2-process-manager-guide

PM2 is a process manager for Node.js: a small background daemon that starts your application, restarts it when it dies, keeps its output in log files, and brings everything back after a reboot. Used properly it is four things - an `ecosystem.config.js` in the repository, `pm2 start` against that file, `pm2-logrotate` so the logs do not fill the disk, and `pm2 save` plus `pm2 startup` so the machine remembers what to run. Most PM2 problems come from skipping one of the four, or from running PM2 somewhere that already supervises your process.

That last point decides whether you need it at all. On a plain virtual or dedicated server, nothing restarts your app until you set something up, and PM2 is a reasonable choice. Inside a container on a hosting panel, the platform is already the supervisor, and a second one hides your crashes from it - [PM2 vs a hosting panel](https://www.renode.space/blog/pm2-vs-a-hosting-panel) covers that case in full and is not repeated here. This guide assumes a server you run yourself and goes through PM2 as it should be set up there.

#### What PM2 is and how it works

PM2 has two parts. The **daemon** is a long-running Node process that owns your applications: it spawns them, watches them, restarts them, and writes their standard output and standard error to files. The **CLI** - the `pm2` command - is a client that talks to the daemon over a socket. When you type `pm2 start`, the CLI starts the daemon if it is not already running, asks it to launch your app, prints a table, and exits. The app keeps running because the daemon keeps running.

Everything PM2 knows lives in `PM2_HOME`, which defaults to `~/.pm2` for the user who ran it. That directory holds the logs (`~/.pm2/logs`), the PID files, the daemon's own log (`~/.pm2/pm2.log`) and the saved process list (`~/.pm2/dump.pm2`). The important consequence: the daemon is per user. If you start your app as `deploy` and later type `sudo pm2 ls`, you are asking root's daemon, which has never heard of it, and you get an empty table. Pick one user, normally an unprivileged one that owns the application directory, and never mix.

Installing it is one global package:

```bash
$ npm install -g pm2
$ pm2 --version
```

PM2's commands and config keys have been stable for years, but defaults occasionally move between major versions, so when a number in this guide matters to you, check it against `pm2 --version` and the official documentation for that release.

#### Starting an app: pm2 start, names and interpreters

The minimal form takes a script and a name. Always give a name; the default is the file name, and four apps all called `index` are unpleasant to manage.

```bash
$ pm2 start dist/server.js --name api
$ pm2 start dist/server.js --name api -- --port 3000   # args after -- go to your app
$ pm2 start npm --name web -- start                    # runs "npm start"
$ pm2 ls
```

`pm2 ls` (also `pm2 list` or `pm2 status`) prints one row per process: id, name, mode (`fork` or `cluster`), the restart counter, status, CPU and memory. The restart counter is the column to watch. A number that climbs while you are not deploying means the app is crashing and PM2 is quietly picking it up again.

Running through `npm start` works, but it puts npm between PM2 and your app: an extra process using memory, and an extra hop for signals on shutdown. Pointing PM2 at the real entry file is better whenever you can.

PM2 is not limited to JavaScript. It picks an interpreter from the file extension - `.py` gets `python`, `.sh` gets `bash`, `.rb` gets `ruby`, `.php` gets `php` - and you can set one explicitly with `--interpreter`. For a compiled binary or anything that is its own executable, the interpreter is `none`:

```bash
$ pm2 start worker.py --name worker --interpreter python3
$ pm2 start ./bin/server --name go-api --interpreter none
$ pm2 start /srv/app/.venv/bin/gunicorn --name web --interpreter none \
    -- app:app --bind 127.0.0.1:8000 --workers 2
```

Two things to know about Python under PM2. First, many current distributions ship `python3` and no `python`, so the extension default fails with an interpreter-not-found error; set the interpreter, and preferably point it at the virtualenv's own binary so the right packages are on the path. Second, Python buffers standard output when it is not attached to a terminal, which under PM2 means your log lines appear in bursts or not at all until the process exits. Set `PYTHONUNBUFFERED=1` in the environment, or run with `-u`. [Python requirements and virtualenvs](https://www.renode.space/blog/python-requirements-and-virtualenvs) covers building the environment itself, and [deploying FastAPI or Flask](https://www.renode.space/blog/deploy-fastapi-or-flask) the server in front of it.

#### The ecosystem file

Command-line flags live in somebody's shell history. An ecosystem file lives in the repository, gets reviewed, and starts the same way on every machine. `pm2 init simple` writes a skeleton; a realistic one looks like this:

ecosystem.config.js:

```js
module.exports = {
  apps: [
    {
      name: "api",
      script: "dist/server.js",
      cwd: "/srv/api",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "400M",
      node_args: "--max-old-space-size=320",
      watch: false,
      time: true,
      kill_timeout: 10000,
      env: { NODE_ENV: "development", PORT: 3000 },
      env_production: { NODE_ENV: "production", PORT: 3000 },
    },
    {
      name: "worker",
      script: "worker.py",
      cwd: "/srv/api",
      interpreter: "/srv/api/.venv/bin/python",
      exec_mode: "fork",
      env: { PYTHONUNBUFFERED: "1" },
    },
  ],
};
```

```bash
$ pm2 start ecosystem.config.js --env production
$ pm2 reload ecosystem.config.js --env production   # after a deploy
$ pm2 start ecosystem.config.js --only worker       # one app from the file
```

The keys that matter, and what they actually do:

| Key | What it does |
| --- | --- |
| `name` | The handle for every other command |
| `script` | Entry file or binary, relative to `cwd` |
| `args` | Arguments passed to your app |
| `cwd` | Working directory; set it explicitly |
| `interpreter` | `node` by default; a path, or `none` |
| `node_args` | Flags for Node itself, such as heap size |
| `instances` | How many processes; `max` means one per CPU |
| `exec_mode` | `fork` (default) or `cluster` |
| `env` / `env_<name>` | Environment, picked with `--env <name>` |

| Key | What it does |
| --- | --- |
| `max_memory_restart` | Restart a process above this memory, e.g. `400M` |
| `autorestart` | `true` by default; `false` for one-off jobs |
| `max_restarts` | Consecutive unstable restarts before giving up |
| `min_uptime` | How long a process must live to count as started |
| `restart_delay` | Fixed pause before each restart, in ms |
| `exp_backoff_restart_delay` | Growing pause between restarts, in ms |
| `kill_timeout` | Wait after the stop signal before `SIGKILL`, in ms |
| `cron_restart` | Restart on a cron expression |
| `time` | Prefix every log line with a timestamp |

A few of those deserve more than a table cell.

**`env` and `env_production`.** The plain `env` block is always applied; `--env production` merges `env_production` over it. That is useful, but it is not a secrets store - the ecosystem file is in the repository. Keep credentials in a file outside the repo with restrictive permissions and load it at the top of the ecosystem file (for example with `require("dotenv").config({ path: "/etc/api/api.env" })`), or have the app read it itself. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) explains why the distinction matters.

**`max_memory_restart`.** PM2 checks memory on an interval, not on every allocation, so it is a tidy-up for slow leaks rather than a hard ceiling. It also restarts the process abruptly mid-request. Keep it above normal peak usage and pair it with a V8 heap limit below it, so Node fails in a way you can read before PM2 or the kernel steps in - [Node memory limits explained](https://www.renode.space/blog/node-memory-limits-explained) has the arithmetic, including why every cluster worker gets its own heap.

**`max_restarts` and `min_uptime`.** A process that exits before `min_uptime` is counted as unstable; after `max_restarts` unstable starts in a row, PM2 stops trying and marks it `errored`. The documented defaults are 16 restarts and 1000 ms. If your app takes several seconds to fail - waiting on a database connection, for instance - it never counts as unstable and PM2 restarts it forever. `exp_backoff_restart_delay: 100` is the better tool: the pause grows with each crash and resets once the app stays up, so a dead database does not turn into a busy loop.

**`kill_timeout`.** On stop, PM2 sends `SIGINT` and waits this long before `SIGKILL`. The default is 1600 ms, which is shorter than a slow request. If your app drains connections on shutdown, raise it to cover your longest request, and make sure the handler listens for `SIGINT` as well as `SIGTERM`. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers the application side.

#### Cluster mode and instances

`exec_mode: "cluster"` uses Node's `cluster` module: PM2 starts several copies of your app and they share one listening port, with incoming connections spread across them. It is the only way one Node app uses more than one CPU core without you writing the forking yourself, and it is what makes zero-downtime reloads possible.

Three facts to set expectations:

- **Cluster mode is Node-only.** A Python, Go or Bun process cannot share a port this way. For those, `instances` above 1 in fork mode starts several copies that all try to bind the same port, and all but the first fail. Use the app server's own workers - Gunicorn's `--workers`, Uvicorn's `--workers` - and leave PM2 at one instance.
- **`instances: "max"` counts the machine's logical CPUs.** On a VDS that is your vCPU count, which is fine. `0` means the same thing, and `-1` means all but one, leaving a core for the database on a single box.
- **Each instance is a full copy of your app.** Memory per instance multiplies, in-memory state is not shared, and anything scheduled inside the app runs once per instance. The trade-offs - sticky sessions, jobs running four times, sizing workers to the cores you actually have - are laid out in the [PM2 vs a hosting panel](https://www.renode.space/blog/pm2-vs-a-hosting-panel) cluster section.

Scaling a running cluster up or down needs no restart:

```bash
$ pm2 scale api 4
$ pm2 scale api +1
```

The change lasts until the next start from the ecosystem file, which still says `instances: 2`. Edit the file if you mean it.

#### Restart, reload, stop and delete

These four are the most confused part of PM2, and the difference is not cosmetic.

| Command | What happens |
| --- | --- |
| `pm2 restart api` | Kills every instance, then starts them again |
| `pm2 reload api` | Cluster mode: replaces instances one at a time |
| `pm2 stop api` | Stops it; it stays in the list |
| `pm2 delete api` | Stops it and removes it from the list |

`restart` has a gap: for a moment there is nothing listening, and requests in that moment fail. `reload` in cluster mode starts a new instance, waits for it to be ready, then stops an old one, and repeats until every instance is new - there is always something accepting connections. In fork mode there is nothing to roll across, and `reload` simply falls back to a restart.

"Ready" by default means the new instance has started listening. If your app needs to warm a cache or open a database pool before it can serve, tell PM2 to wait for an explicit signal:

ecosystem.config.js (excerpt):

```js
wait_ready: true,
listen_timeout: 10000,
```

server.js:

```js
server.listen(process.env.PORT, () => {
  if (process.send) process.send("ready");
});
```

If the signal never arrives within `listen_timeout`, PM2 carries on anyway, so a broken release still replaces a working one. Reload is a convenience, not a health check; [zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers what else has to be true for a deploy to be truly gapless.

One more trap. PM2 captures the environment when a process is first started and reuses it. Changing an environment variable and running `pm2 restart api` keeps the old value. Add `--update-env`, or restart from the ecosystem file:

```bash
$ pm2 reload ecosystem.config.js --env production --update-env
```

And `stop` versus `delete` matters because of the next section: a stopped process is still in the list and will be saved as stopped; a deleted one is gone only until you resurrect an old dump that still contains it.

#### Logs and pm2-logrotate

Every process gets two files: `~/.pm2/logs/<name>-out.log` for standard output and `~/.pm2/logs/<name>-error.log` for standard error. In cluster mode each instance writes its own pair with the id in the name unless you set `merge_logs: true`.

```bash
$ pm2 logs                    # all apps, streaming
$ pm2 logs api --lines 200    # last 200 lines, then stream
$ pm2 logs api --err          # error stream only
$ pm2 flush api               # empty this app's log files
```

Set `time: true` in the ecosystem file unless your logger already writes timestamps. Without them, a log file is a list of events with no idea when any of them happened.

Out of the box, those files grow forever. A chatty app writing a few hundred kilobytes a minute fills a small disk in weeks, and a full disk takes the database down with it. The fix is a PM2 module:

```bash
$ pm2 install pm2-logrotate
$ pm2 set pm2-logrotate:max_size 20M
$ pm2 set pm2-logrotate:retain 14
$ pm2 set pm2-logrotate:compress true
$ pm2 conf pm2-logrotate
```

| Setting | Default | What it does |
| --- | --- | --- |
| `max_size` | `10M` | Rotate a file once it passes this size |
| `retain` | `30` | Rotated files to keep per log |
| `compress` | `false` | Gzip rotated files |
| `rotateInterval` | `0 0 * * *` | Also rotate on this cron, daily by default |

The module runs as its own process inside the daemon and appears in `pm2 ls`, which is normal. The settings are per user, like everything else in PM2. If you would rather keep logs in the system journal, systemd does that without a module; [logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) is about what should be in them at all.

#### pm2 save and pm2 startup: surviving a reboot

PM2 does not start at boot by itself. Two commands fix that, and they do different jobs.

`pm2 startup` generates a systemd unit that starts the PM2 daemon for your user at boot. Run as a normal user, it does not install anything; it prints the command that would, because installing a unit needs root:

```bash
$ pm2 startup
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy
```

Copy that line exactly and run it. It writes and enables a unit named `pm2-deploy.service` (for the user `deploy`). The paths in it depend on where Node is installed on your machine, so do not copy one from a tutorial.

`pm2 save` writes the current process list to `~/.pm2/dump.pm2`. At boot, the unit starts the daemon and runs `pm2 resurrect`, which restarts whatever was in that file. The sequence is therefore:

1. Start everything you want running: `pm2 start ecosystem.config.js --env production`.
2. Check it: `pm2 ls`, all `online`, restart counters at zero.
3. Save it: `pm2 save`.
4. Install the boot unit once: `pm2 startup`, then run the printed `sudo` line.
5. Test it: `sudo reboot`, log back in, `pm2 ls`.

Step 5 is the one people skip, and a reboot is not the time to discover that the list was never saved.

> [!WARNING]
> `pm2 save` saves whatever is running at that moment, including nothing. If you `pm2 delete all` while experimenting and then save, the dump is empty and the next reboot brings back no apps. Save after every change you mean to keep, and only then.

If Node's path changes - a new version through `nvm`, for example - the unit still points at the old binary. Re-run the pair: `pm2 unstartup systemd`, then `pm2 startup` and its printed command, then `pm2 save`. After upgrading the PM2 package itself with `npm install -g pm2`, run `pm2 update` so the running daemon is replaced by the new version rather than left on the old one.

You can check the unit like any other: `systemctl status pm2-deploy`. If you are going to read systemd output anyway, it is worth knowing you could run the app as its own unit with no PM2 at all - [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) covers that, and it is the leaner option for a single process.

#### Watching it: pm2 monit, describe and the restart counter

```bash
$ pm2 monit          # terminal dashboard: CPU, memory, logs per process
$ pm2 describe api   # full detail: paths, restarts, uptime, env
$ pm2 reset api      # zero the restart counter
```

`pm2 monit` is a live terminal view with a process list, per-process CPU and memory, and a log pane. It is handy during a deploy or a load test and useless at four in the morning, because it only shows anything while someone is looking at it. `pm2 describe` is the command for a post-mortem: it prints the script path, interpreter, log file locations, uptime, restart count, and unstable restarts.

The restart counter is the cheapest alert you have. Reset it after a deliberate deploy (`pm2 reset api`), and any non-zero number afterwards is a crash you did not cause. If nobody is going to look at it, a small cron job that runs `pm2 jlist`, which prints the process list as JSON, and complains when `restart_time` or `status` changes is a dozen lines of script and catches crash loops long before users do. PM2 also has a paid hosted dashboard; nothing in this guide needs it.

#### Common mistakes

**Watch mode in production.** `watch: true` restarts the app whenever a file in its directory changes. In production that means every log write inside the project, every upload, every cache file triggers a restart, and the restart writes a log line, which triggers another. Keep `watch: false` outside development. If you do use it locally, add `ignore_watch: ["node_modules", "logs", "uploads"]`.

**Two daemons.** One started as root, one as your user, each with half the apps. Run `pm2 ls` and `sudo pm2 ls` once and make sure only one of them has anything in it. Then delete from the other and `pm2 kill` it.

**Forgetting `pm2 save`.** Everything works until the first reboot. Make the save part of your deploy script.

**Two supervisors.** PM2 inside a container whose platform already restarts it, or PM2 started from a systemd unit you wrote yourself while `pm2 startup` also installed one. Pick one supervisor per process.

**`instances: "max"` with stateful code.** Sessions held in memory, WebSockets tracked in a `Map`, a scheduler inside the app. All break or multiply the moment there is a second instance.

**Python logs that never appear.** Unbuffered output is off by default. `PYTHONUNBUFFERED=1`.

**Arguments in the wrong place.** In `pm2 start server.js --name api -- --port 3000`, everything after `--` goes to your app. Node flags such as `--max-old-space-size` belong in `node_args` or `--node-args`, not after the double dash, or they are silently ignored.

**Treating `max_memory_restart` as a fix.** It hides a leak by restarting the process on a schedule you did not choose. Find the leak.

#### FAQ

##### Is PM2 free?

Yes. The process manager is open source and everything in this guide uses only the free CLI and the free `pm2-logrotate` module. The paid part is an optional hosted monitoring dashboard, which you do not need to run PM2.

##### Should I use PM2 or systemd?

For one process on a server, systemd is already installed and does the job with no extra daemon. PM2 earns its place when you want Node cluster mode with rolling reloads, or several small apps managed with one command. Both are fine; running both for the same app is not.

##### Can PM2 run Python, Go or a shell script?

Yes. Set `interpreter` to the Python binary (ideally inside the virtualenv), to `none` for a compiled binary, or let `.sh` files default to bash. Cluster mode and `pm2 reload` without downtime work only for Node, so other runtimes get one instance and their own worker model.

##### Where does PM2 keep my logs?

In `~/.pm2/logs` of the user who started the app, as `<name>-out.log` and `<name>-error.log`. `pm2 describe <name>` prints the exact paths. Install `pm2-logrotate` before they become a disk problem.

##### Why did my app not come back after a reboot?

Either `pm2 startup` was never installed, or `pm2 save` was not run after the app was started, or it was saved under a different user. Check `systemctl status pm2-<user>`, then `pm2 ls` as that user, then start, save and reboot again.

##### Should I use PM2 on a hosting panel?

Usually not. A panel already restarts the process, keeps its output and tracks crashes, and PM2 in between hides those crashes from it. Start the app directly in the foreground instead.

A RE:NODE VDS is a whole machine in Germany where PM2 or systemd is yours to set up, prepared by hand within 24 hours. If you would rather not supervise processes at all, the app hosting lines on /app-hosting run Node.js, Python and other runtimes with restarts, a live console and GitHub deploys handled by the panel.

[See VDS plans](https://www.renode.space/dedicated-servers)

---

### Telegram webhook not working: reading getWebhookInfo

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/telegram-bot-webhook-troubleshooting

When a Telegram bot on a webhook stops receiving updates, the answer is almost always sitting in one API call: `getWebhookInfo`. It tells you which URL Telegram thinks it should deliver to, which IP address it resolved, how many updates are queued, and - most usefully - the last error your server returned, quoted back to you in plain text. Read `url`, `pending_update_count` and `last_error_message` together and you know which of four things is wrong: the registration, the TLS, the HTTP response, or the speed of your handler. Fix that one thing, and the queued updates drain on their own.

This post is the troubleshooting half of the subject. The choice between polling and webhooks, BotFather settings, library code and deployment are covered in [Telegram bot hosting: polling and webhooks](https://www.renode.space/blog/telegram-bot-hosting-guide); here the webhook is already set and something is not arriving.

#### Start with getWebhookInfo

It takes no parameters and works from any machine with the token, so you can run it from your laptop while the bot runs elsewhere:

```bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"
```

A broken webhook looks something like this (formatted for reading):

```json
{
  "ok": true,
  "result": {
    "url": "https://bot.example.com/tg/hook",
    "has_custom_certificate": false,
    "pending_update_count": 37,
    "ip_address": "203.0.113.10",
    "last_error_date": 1760511600,
    "last_error_message": "Wrong response from the webhook: 404 Not Found",
    "max_connections": 40,
    "allowed_updates": ["message", "callback_query"]
  }
}
```

The fields, as the Bot API defines them:

| Field | What it tells you |
| --- | --- |
| `url` | Where Telegram delivers. Empty means no webhook - the bot is in polling mode |
| `has_custom_certificate` | `true` if you uploaded a certificate with `setWebhook` |
| `pending_update_count` | Updates waiting to be delivered |
| `ip_address` | The address Telegram is actually sending to |
| `last_error_date` | Unix time of the most recent delivery failure |
| `last_error_message` | Human-readable text of that failure |
| `last_synchronization_error_date` | A sync error inside Telegram, not on your side |
| `max_connections` | Concurrent deliveries allowed, 1-100 |
| `allowed_updates` | Update types the bot is subscribed to |

The optional fields simply disappear when they have nothing to say, so a healthy webhook shows no `last_error_*` keys at all, or only an old date. `last_error_date` is seconds since the epoch; `date -d @1760511600` on Linux turns it into something readable, and comparing it with the current time is the first thing to do. An error from three days ago with `pending_update_count` at zero is history, not a problem.

#### Reading the fields together

No single field diagnoses anything. The combinations do:

| What you see | What it means |
| --- | --- |
| `url` is empty | No webhook is registered. Something called `deleteWebhook`, or a poller cleared it |
| `url` is wrong or old | A stale registration: old hostname, old path, staging URL |
| Count rising, recent error | Telegram cannot deliver and is retrying |
| Count rising, no recent error | Deliveries succeed but your handler is too slow |
| Count zero, no error, no replies | Updates arrive; the bug is in your code or `allowed_updates` |

The first row catches more people than it should. Several libraries call `deleteWebhook` when you start them in polling mode, so running the bot locally once "to test something" with the production token silently removes the production webhook. The webhook does not come back until something calls `setWebhook` again. If your deploy only registers the webhook when the URL changes, it may never come back by itself.

The last row is the one where `getWebhookInfo` cannot help you, because from Telegram's side everything is fine: it posted the update, your server answered `200`, done. Check `allowed_updates` first - if it lists only `message`, button presses (`callback_query`) never arrive, and `chat_member` updates are only sent when you ask for them explicitly. Then check your logs; an exception swallowed after the `200` was sent looks exactly like silence.

`ip_address` deserves a glance every time. It is the address Telegram resolved from your hostname, and if it is not the server you think it is, you have a DNS problem: an A record still pointing at an old host, or a record that has not finished propagating. Telegram's webhook delivery is IPv4 only - the webhook guide states plainly that IPv6 is not supported - so a hostname with only an AAAA record cannot receive webhooks at all. [DNS records explained](https://www.renode.space/blog/dns-records-explained) covers checking which records a name actually has.

#### SSL errors

Any `last_error_message` beginning with `SSL error` means Telegram reached your address and refused the TLS handshake. The text usually contains an OpenSSL error string such as `certificate verify failed`, and the exact wording has changed as Telegram's OpenSSL version has changed, so match on the meaning rather than the code.

Telegram's published requirements are short and strict:

- TLS 1.2 or newer. SSLv2, SSLv3, TLS 1.0 and 1.1 are refused.
- The certificate's CN or a SAN must match the hostname in the webhook URL.
- The server must send the full chain - your certificate plus every intermediate.
- A self-signed certificate works only if you upload it with the `certificate` parameter of `setWebhook`, after which `has_custom_certificate` shows `true`.
- TLS is required on every allowed port, including 80. Port 80 is permitted, plain HTTP is not.

The missing intermediate is the classic. Browsers often paper over it because they cache intermediates or fetch them themselves, so the site looks fine in Chrome while Telegram, which does neither, rejects it. Test the way a strict client would:

```bash
$ openssl s_client -connect bot.example.com:443 -servername bot.example.com </dev/null \
    | grep -E "Verify return code|subject=|issuer="
```

`Verify return code: 0 (ok)` is what you want. Code 21 (`unable to verify the first certificate`) is the missing chain: in nginx that means `ssl_certificate` points at the leaf certificate instead of the full chain file. Certbot writes both, and `fullchain.pem` is the one to use. An expired certificate gives code 10, and a hostname mismatch is visible in the `subject=` line. [SSL certificate errors explained](https://www.renode.space/blog/ssl-certificate-errors-explained) goes through each of these in detail.

One less obvious cause: a self-signed certificate uploaded months ago, then replaced with a real one without re-registering. Telegram still pins the old one. Call `setWebhook` again without the `certificate` parameter and `has_custom_certificate` goes back to `false`.

#### Wrong response from the webhook

`Wrong response from the webhook: <status>` means the TLS handshake worked and your server answered, but not with a 2xx. Telegram treats anything outside 2xx as a failure and retries. The status in the message is the clue:

| Status | Usual cause |
| --- | --- |
| `404 Not Found` | Path mismatch between the registered URL and your route |
| `401` / `403` | Your secret-token check rejecting Telegram (see below) |
| `405 Method Not Allowed` | Route registered for GET; Telegram only POSTs |
| `301` / `302` / `308` | A redirect, which Telegram treats as a failure |
| `500 Internal Server Error` | Your handler threw before responding |
| `502` / `503` / `504` | The proxy is up, the app behind it is not |

The 404 is almost always a trailing slash, a prefix, or a path with the token in it that no longer matches since the token was revoked. Compare `url` from `getWebhookInfo` character by character with the route in your code. Libraries that build the route from a `url_path` option and the URL from a separate `webhook_url` option make it easy to change one and not the other.

Redirects come from the layer in front of your app: a redirect from the bare domain to `www` or the reverse, a proxy that sends one path prefix somewhere else, or a framework that appends a trailing slash. Register the exact final URL, the one that answers without a redirect.

The 5xx group splits cleanly. A `500` came from your code, so the stack trace is in your application log. A `502` came from the reverse proxy, which means the app is down, crashed on start, or is listening somewhere the proxy is not looking. The commonest form is an app bound to `127.0.0.1` inside a container while the proxy connects from outside it, or an app listening on a hard-coded port that is not the one the plan allocated. [What a reverse proxy actually does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains why the proxy and the app can disagree about where the app lives.

One more source of 500s that looks like a Telegram problem: a handler that crashes on one particular update. Telegram retries it, the handler crashes again, and because the update keeps failing the rest of the queue grows behind it. A single malformed message can stall the bot until that update is dropped. Wrap your update processing so an exception is logged and the request still gets its `200`.

#### Timeouts and connection errors

When the message talks about the connection rather than a status, Telegram never got an HTTP answer at all. The wording varies, but the families are:

- **Connection refused** - the address resolved and something replied that nothing is listening on that port. The proxy is not running, or the port in the URL is not open.
- **Connection timed out** - packets went nowhere. A firewall dropping traffic, a wrong IP, or a home router with no port forward.
- **Read timeout expired** - the connection opened and your server took too long to answer. This is a slow handler, not a network problem.
- **Failed to resolve host** - the hostname has no A record Telegram can use.

Telegram does not publish its delivery timeout, so do not design to a number. Design so that the answer takes milliseconds regardless.

If you restrict inbound traffic by source address, Telegram's webhook guide lists the ranges it delivers from: `149.154.160.0/20` and `91.108.4.0/22`. The same page says they may change, so an allow-list built from them is something to recheck when deliveries suddenly start timing out. Most people are better served by the secret token than by an IP allow-list. [Firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter) covers the trade-off.

#### Ports and the setWebhook errors

Webhooks are delivered to exactly four ports: `443`, `80`, `88` and `8443`. Not 3000, not 5000, not 8080. `setWebhook` refuses any other port with a `bad webhook` error at registration time, so if you are seeing that error you have not got as far as a delivery failure. Other refusals at registration: a URL that is not HTTPS, and a hostname that does not resolve.

The port in the URL is the port Telegram connects to, not the port your app listens on. The normal arrangement is a reverse proxy terminating TLS on 443 and forwarding to the app's own port:

```diagram
title: Where a webhook delivery can fail
tg = Telegram | 149.154.160.0/20
dns = DNS lookup | A record, IPv4
proxy = Reverse proxy | TLS on 443
app = Bot app | 0.0.0.0, plan port
tg -> dns : Failed to resolve host
dns -> proxy : SSL error, refused, timeout
proxy -> app : 502 if the app is down
app --> tg : 200 within milliseconds
```

Each arrow is a different family of `last_error_message`, which is why that field narrows the search so quickly. On RE:NODE app plans this arrangement is the proxy slot: point an A record at the address shown, and the certificate is issued and renewed automatically inside a 21-day window, with TLS on 443 and the request forwarded to the port your app listens on. The app still has to listen on `0.0.0.0` and the plan's port; that part is yours. Running the proxy yourself on your own machine is covered in the [nginx reverse proxy guide](https://www.renode.space/blog/nginx-reverse-proxy-guide).

The `ip_address` parameter of `setWebhook` lets you give Telegram a fixed IP to send to instead of resolving the hostname. TLS is still checked against the hostname. It is useful when DNS is in transition and you cannot wait, and a trap when you forget it was set and later move the bot - the new A record is then ignored. `getWebhookInfo` shows whichever address is in use.

#### The backlog and drop_pending_updates

While deliveries fail, updates queue. Telegram keeps undelivered updates for up to 24 hours, retries delivery, and once you fix the cause the backlog is delivered in a rush - limited by `max_connections`, which defaults to 40 concurrent requests. That has two effects people do not expect. A bot that was broken for a night answers a night's worth of messages within seconds of the fix, replying to conversations that ended hours ago. And on a small server, 40 simultaneous deliveries can be enough load to cause a second round of timeouts.

When the backlog is worthless, throw it away. Both `setWebhook` and `deleteWebhook` take `drop_pending_updates`:

```bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
    -d "url=https://bot.example.com/tg/hook" \
    -d "secret_token=$WEBHOOK_SECRET" \
    -d "max_connections=10" \
    -d "drop_pending_updates=true"
```

`pending_update_count` should read `0` straight afterwards. Note that `setWebhook` replaces the whole configuration: leave out `secret_token` or `allowed_updates` on a re-registration and they are gone. Always pass the full set you want.

Do not drop the backlog by reflex. For a bot that records orders, payments or form submissions, those queued updates are real work, and the right move is to fix the fault, lower `max_connections` to something your server handles, and let them drain. Watch the count fall with repeated `getWebhookInfo` calls.

#### The secret_token header

`secret_token` is a string you choose, 1-256 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, passed to `setWebhook`. Telegram then sends it in an `X-Telegram-Bot-Api-Secret-Token` header on every delivery, and your handler compares it and rejects anything that does not match. It is the cheap, correct way to make sure only Telegram can feed your bot updates.

It also produces a specific failure. Your handler returns 401 or 403, `last_error_message` shows `Wrong response from the webhook: 403 Forbidden`, and the cause is one of three things:

1. The secret was rotated in your environment but `setWebhook` was not called with the new value.
2. A re-registration left `secret_token` out, so Telegram now sends no header at all.
3. A proxy in between strips unfamiliar headers. Uncommon, but worth checking if the first two are ruled out.

The fix for all three is the same: set the variable, call `setWebhook` with the same value, and check that your environment and Telegram agree. The secret is not visible in `getWebhookInfo`, so the only proof is a delivery that succeeds. Keep it next to the token, not in the repository - [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) has the reasons.

#### Respond fast, then process

If `pending_update_count` climbs with no error, or you see read timeouts, your handler is doing its work before it answers. Every delivery holds one of the `max_connections` slots until you respond. A handler that calls an external API, writes to a database and sends two messages before returning `200` might take two seconds; with a burst of messages, the slots fill, deliveries queue, and eventually some time out and are retried - which adds to the load.

The fix is structural: verify the secret, hand the update to something else, answer `200`, and do the work afterwards.

FastAPI, answer first:

```python
import asyncio, os
from fastapi import FastAPI, Request, Response

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"]
queue: asyncio.Queue = asyncio.Queue()

@app.post("/tg/hook")
async def hook(request: Request):
    if request.headers.get("X-Telegram-Bot-Api-Secret-Token") != SECRET:
        return Response(status_code=403)
    await queue.put(await request.json())
    return Response(status_code=200)

async def worker():
    while True:
        update = await queue.get()
        try:
            await handle(update)          # your bot logic
        except Exception as exc:
            print("update failed:", exc, flush=True)

@app.on_event("startup")
async def start_worker():
    asyncio.create_task(worker())
```

An in-memory queue loses whatever is in it if the process restarts, which is acceptable for a chat bot and not for anything that takes payments. When the work must survive a restart, put it in a real queue; [background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) covers the options that fit a single plan. Some libraries, grammY's `webhookCallback` among them, apply their own timeout to the handler and answer on your behalf when it runs out - better than a Telegram timeout, but a sign the handler is too slow, not a solution.

Also lower `max_connections` to what the server can actually run in parallel. On a 0.5 or 1 vCPU plan, 40 concurrent deliveries is not capacity, it is contention. Something between 5 and 20 is usually honest for a small bot.

#### Going back to polling

Sometimes the right fix is to stop using a webhook. If the bot has a few hundred users, no domain to spare, or you are debugging and want to see updates arrive in a terminal, polling is simpler and loses nothing that matters.

```bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook" \
    -d "drop_pending_updates=false"
```

With `drop_pending_updates=false` (the default), the queued updates stay and `getUpdates` delivers them on the next poll. Afterwards `getWebhookInfo` returns an empty `url`, and polling works. Until you delete it, every `getUpdates` call fails with a 409 conflict, which is how people discover that a webhook they forgot about is still registered. Remember the reverse as well: going back to the webhook later needs a fresh `setWebhook` with the full parameter set, and the deploy should check `getWebhookInfo` afterwards rather than assume.

#### A checklist in order

When it is late and the bot is silent, go through it in this order:

1. Run `getWebhookInfo`. Is `url` set, and is it exactly the URL your code serves?
2. Is `ip_address` your server? If not, fix DNS, or clear a fixed `ip_address`.
3. Read `last_error_date`. Is the error recent? If not, the problem is in your code or `allowed_updates`.
4. `SSL error`: check the chain and hostname with `openssl s_client`.
5. `Wrong response`: the status tells you whether it is path, secret, redirect, or a crashed app.
6. Connection errors: check the port is one of the four, the proxy is running, and nothing is firewalled.
7. Count rising with no error: answer first, process later, lower `max_connections`.
8. Fixed and the backlog is stale: re-register with `drop_pending_updates=true`.

Keep your application log open while you work. [How to read a server console without guessing](https://www.renode.space/blog/reading-the-console) helps separate the errors that matter from the noise around them.

#### FAQ

##### Why is pending_update_count growing when there is no error?

Telegram is delivering successfully but your server answers too slowly to keep up, so deliveries wait for a free connection slot. Return `200` as soon as the request is validated and do the work in a background task, and set `max_connections` to what your server can genuinely handle in parallel.

##### How long does Telegram keep updates my webhook did not receive?

Up to 24 hours. Anything older is discarded whether or not you fix the webhook in time, so a bot that was down for two days loses the first day's messages regardless.

##### Can I use a webhook on port 3000 or 8080?

Not directly. Telegram delivers only to 443, 80, 88 and 8443, and `setWebhook` rejects other ports. Run the app on any port you like and put a reverse proxy on 443 in front of it.

##### Does Telegram follow redirects to my webhook?

Treat it as no. A 3xx response is recorded as a wrong response and the delivery is retried. Register the exact final URL, including the scheme, host and any trailing slash your framework expects.

##### Will deleteWebhook lose my messages?

Not unless you pass `drop_pending_updates=true`. Without it the queued updates stay on Telegram's side and are handed to the first `getUpdates` call, within the 24-hour limit.

##### Do I need the IP allow-list if I use secret_token?

No. The secret token already proves a request came from Telegram, and it does not break when Telegram changes its address ranges. An allow-list is an extra layer, not a replacement, and it is the one more likely to cause an outage.

Node JS and Python plans on RE:NODE start at 1 GB with 0.5 vCPU from $4 a month. Webhook bots get a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, with TLS on port 443 in front of your app and the bot token kept as an environment variable on the Startup tab.

[See app hosting](https://www.renode.space/app-hosting)

---

### Bun or Deno on a small server: which runtime to pick

Published 2026-10-14, updated 2026-10-14 by RE:NODE Team. https://www.renode.space/blog/bun-or-deno-on-a-small-server

For a small app on a small server, Node.js is still the safe default, Bun is the one to try when you want the shortest path from a TypeScript file to a running HTTP server, and Deno is the one to pick when you want the runtime to refuse to do things you did not ask for. All three run the same kind of program: a long-lived process that listens on a port and answers HTTP. The differences that matter on a hosted plan are small and practical - how the server is started, how dependencies are installed, how TypeScript is handled, what the lockfile is called, and which packages from npm quietly fail. This post goes through each of those with real commands, and ends with how to choose and what to check when the app starts but nothing answers.

There are no benchmark numbers in here on purpose. Throughput and memory figures for runtimes depend on the version, the workload and the machine, and the numbers in most comparison posts are for a hello-world handler that no real app resembles. Where memory matters, the advice is to measure your own app on your own plan.

#### What each runtime is

**Node.js** is the incumbent. It runs JavaScript on the V8 engine, ships with `npm`, and has by far the largest body of tutorials, hosting support and battle-tested libraries. TypeScript is not run directly by default: you compile it, or use a loader or a flag that strips types, and what is supported depends on the Node version you run.

**Bun** is a single executable that is a runtime, a package manager, a test runner and a bundler. It uses the JavaScriptCore engine rather than V8, aims for compatibility with Node's APIs and with npm packages, and runs `.ts` and `.tsx` files directly. Its own server API is `Bun.serve`. Its package manager is `bun install`, and the lockfile is `bun.lock`.

**Deno** is a runtime built around a security model and web-standard APIs. It runs TypeScript directly, ships with a formatter, linter, test runner and type checker, and runs code with no access to the network, environment or file system until you grant it. Its server API is `Deno.serve`. Configuration lives in `deno.json`, and tasks are defined there.

The first thing to settle is that these are not three flavours of the same thing. They share a language and a large part of the standard web API surface - `fetch`, `Request`, `Response`, `URL`, `AbortSignal`, streams - which is why a small handler can often be written once and moved between them. Beyond that they diverge, and the divergence is where the time goes.

| Runtime | Engine | Default TypeScript story |
| --- | --- | --- |
| Node.js | V8 | Compile first, or a version-dependent flag |
| Bun | JavaScriptCore | Runs `.ts` files directly |
| Deno | V8 | Runs `.ts` files directly, with an optional type check |

Check the version you actually have before relying on any behaviour in this post. All three move quickly, and `node --version`, `bun --version` and `deno --version` are the first lines of any bug report.

#### A server in Bun

The smallest useful Bun server is one file:

server.ts:

```js
const port = Number(process.env.PORT) || 3000;

const server = Bun.serve({
  port,
  hostname: "0.0.0.0",
  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/healthz") {
      return Response.json({ ok: true, uptime: process.uptime() });
    }
    return new Response("hello from bun\n");
  },
});

console.log(`listening on ${server.port}`);
```

Run it with `bun run server.ts`, or just `bun server.ts`. The handler takes a standard `Request` and returns a standard `Response`, so there is no framework to install. Routing is yours to write, which is fine for a handful of endpoints and tedious beyond that. Frameworks built on web-standard requests, such as Hono and Elysia, run on Bun and are the usual step up. Express also runs on Bun through its Node compatibility layer, with the caveats in the compatibility section below.

Three details are worth knowing. First, `hostname: "0.0.0.0"` is spelled out here for clarity; the point is that the server must listen on all interfaces, because a server bound to loopback inside a container is unreachable from the proxy in front of it. Second, Bun reads a `.env` file in the working directory automatically, and both `process.env` and `Bun.env` expose the variables. That is convenient locally and a trap on a server, where a stray `.env` committed to the repository will quietly override your intentions or leak a secret. Keep real secrets in the environment. Third, `bun --hot server.ts` reloads on change during development, which is not something to run in production.

Dependencies come from `bun install`. It reads `package.json`, writes `node_modules`, and records the tree in `bun.lock`. Older Bun versions wrote a binary file called `bun.lockb`; current versions write the text `bun.lock`, which diffs properly in a pull request. If your repository holds an old `bun.lockb`, check which one your Bun version reads before you delete either. For a deploy, the equivalent of `npm ci` is:

```bash
$ bun install --frozen-lockfile
```

That installs exactly what the lockfile says and fails if `package.json` and the lockfile disagree, for the same reason [npm ci](https://www.renode.space/blog/npm-ci-vs-npm-install) exists. Add `--production` to skip devDependencies when nothing in your start path needs them.

#### A server in Deno

The same program in Deno:

main.ts:

```js
const port = Number(Deno.env.get("PORT")) || 8000;

Deno.serve({ port, hostname: "0.0.0.0" }, (req: Request) => {
  const url = new URL(req.url);
  if (url.pathname === "/healthz") {
    return Response.json({ ok: true });
  }
  return new Response("hello from deno\n");
});
```

`Deno.serve` takes an options object and a handler, and the handler is again a function from `Request` to `Response`. The default port is `8000` if you pass none, and the default hostname listens on all interfaces, but writing both down costs nothing and removes a guess from the troubleshooting later.

Running it is where Deno differs most, because of permissions:

```bash
$ deno run --allow-net --allow-env=PORT main.ts
```

Without `--allow-net` the program is refused at the moment it tries to open the socket. Without `--allow-env` the call to `Deno.env.get` fails with a permission error (or prompts, if you run it interactively). The model is deny by default, and it is genuinely useful for a small server: a dependency that suddenly wants to read your environment or open a connection somewhere stops with an error rather than succeeding silently.

Project configuration goes in `deno.json`, and the part you will use on a host is `tasks`:

deno.json:

```json
{
  "tasks": {
    "start": "deno run --allow-net --allow-env=PORT,DATABASE_URL main.ts",
    "dev": "deno run --watch --allow-net --allow-env main.ts",
    "check": "deno check main.ts"
  },
  "imports": {
    "hono": "npm:hono@4"
  }
}
```

`deno task start` runs the command in the task. The `imports` block is an import map: it gives bare names to packages so your code can write `import { Hono } from "hono"`. The `npm:` prefix pulls a package from the npm registry, and `jsr:` pulls from JSR, Deno's own registry. Deno writes a `deno.lock` file to pin what it fetched, and the `--frozen` flag makes an install or run fail if the lockfile would change. If you keep a `package.json` in a Deno project, current versions read it and can install into `node_modules`; that is the route to take when you are moving an existing Node project rather than starting fresh.

#### Permission flags, and what to grant

Permissions are the main reason to choose Deno, and they are worth using properly instead of reaching for the allow-everything flag.

| Flag | Grants | Narrow form |
| --- | --- | --- |
| `--allow-net` | Network access | `--allow-net=0.0.0.0:8000,api.example.com` |
| `--allow-env` | Reading environment variables | `--allow-env=PORT,DATABASE_URL` |
| `--allow-read` | Reading files | `--allow-read=./static,./data` |
| `--allow-write` | Writing files | `--allow-write=./data` |

There are more (`--allow-run` for spawning processes, `--allow-ffi`, `--allow-sys`), and `-A` or `--allow-all` switches the lot on. For a server that listens, reads two environment variables and serves a directory, the honest set is the first three above, each narrowed:

```bash
$ deno run \
    --allow-net=0.0.0.0:8000,db.internal:5432 \
    --allow-env=PORT,DATABASE_URL \
    --allow-read=./static \
    main.ts
```

Two cautions. The narrow forms are only as good as your list: a library you added last month may need an extra environment variable, and the failure is a permission error at the first request that touches it, not at startup. Run the app through its real paths before trusting the list. And `-A` is a legitimate choice for a private tool, but then you have opted out of the feature that justified the runtime, so be honest with yourself about it.

> [!NOTE]
> Deno's permissions limit what your own process may do. They are not a sandbox against the host, and they do not replace the isolation your hosting plan already gives the container. Treat them as a way to catch mistakes and surprising dependencies.

Bun and Node have no equivalent switch. Newer Node versions have an experimental permission model behind a flag, and its status changes between releases, so read the documentation for the version you run before building on it.

#### TypeScript without a build step

The quiet appeal of both Bun and Deno is that `main.ts` runs as is. There is no `tsc`, no `dist` directory, no source maps to keep aligned with a running process, and no devDependency on the compiler, which sidesteps the `tsc: not found` trap described in the npm ci post. For a small app that removes a whole stage from the deploy.

What you get is slightly different in each:

- **Bun** transpiles TypeScript by stripping and transforming it, and does not type check. A type error does not stop the program; it simply is not noticed. Run `tsc --noEmit` in CI if you want the checks.
- **Deno** also runs the file without insisting on a check by default for `deno run`, and offers `deno check` to type check explicitly. Putting `deno check main.ts` in CI, or as a task, gives you the safety net.
- **Node** can run TypeScript in recent versions by stripping types, with limits on which TypeScript features are supported. If you depend on that, pin the Node version and read the notes for it.

The practical rule is the same everywhere: running TypeScript is not the same as checking it. The runtime will happily start a program with a type error in it, and the first you hear is a `TypeError` in the log at runtime. Check types in CI or before you push, and treat the server as the place the program runs, not where it is verified.

There is one more catch. TypeScript features that need code generation, such as legacy decorators and some uses of `enum` and `namespace`, are handled differently by different tools. Settings in `tsconfig.json` such as `experimentalDecorators` or `paths` are read by Bun; Deno reads compiler options from `deno.json` and has its own view of module resolution, including requiring explicit file extensions in relative imports. A project written for a bundler that resolves `./utils` without an extension will fail on Deno until the imports say `./utils.ts`.

#### npm compatibility and what breaks

Most of npm works on both. Pure JavaScript packages almost always do, and so do the popular frameworks. The failures cluster in a few places, and it is worth knowing them before you move an existing project.

- **Native addons.** Packages that compile C or C++ at install time and load a `.node` binary, such as some image, crypto and database drivers, depend on Node's native addon interface. Bun supports Node-API for many of them and Deno supports it behind permission, but a package that reaches into V8 internals will not load. Prefer packages that ship WebAssembly or have a pure JavaScript fallback.
- **Install scripts.** `bun install` does not run the lifecycle scripts of arbitrary dependencies by default; it keeps a list of trusted packages and you add to it with `trustedDependencies` in `package.json`. A package that needs `postinstall` to download a binary (Prisma's engines, Puppeteer's browser, `sharp`'s platform build in some setups) can arrive without the file it needs. Deno also requires you to opt in to lifecycle scripts, with an `--allow-scripts` flag on install.
- **Node built-ins at the edges.** Both runtimes implement most of `node:fs`, `node:http`, `node:crypto`, `node:stream` and friends, and both aim to track Node's behaviour. The gaps are in the rarely used corners: `node:cluster`, `node:vm` details, some `worker_threads` behaviour, `node:inspector`, and the exact timing of streams and events. Code that depends on subtle behaviour is where a port goes wrong.
- **Frameworks that assume a Node server object.** Express on Bun works through the `node:http` compatibility layer. Express on Deno works through `npm:express` with the same layer and the right permissions. Both paths work, but you are running Node's API on a non-Node runtime, and the bug that appears will be in the seam. A framework built for web-standard `Request` and `Response` avoids the seam entirely.
- **Tooling that expects Node.** Anything that shells out to `node`, reads `process.versions.node` to branch, or is documented only for `npm run` and `npx` needs a check. `bunx` and `deno run npm:package` replace `npx`.

The way to find out is boring: install, start the app, call every route, run the test suite under the new runtime, and read the log for warnings. A package that is "supported" in the changelog can still fail in the one code path you use. If the project is mostly a pile of Node-specific dependencies, the cost of moving usually outweighs what you gain.

#### Memory, startup and the small-plan question

People move to Bun or Deno hoping for lower memory use or faster starts. Sometimes they get it; sometimes they do not. This is where a published number would be dishonest, so here is what can be said safely.

Resident memory depends on the engine's baseline, the amount of code loaded, the size of your data in memory, the garbage collector's habits and the allocator. Two runtimes running the same app can differ in either direction, and the idle figure rarely predicts the figure under load. JavaScriptCore and V8 collect garbage differently, and a process that looks lean when idle can still grow under a particular request pattern. Neither engine is a guarantee of a smaller footprint.

What you can do is measure the thing you will actually run:

1. Start the app on the plan you intend to buy, with a realistic copy of its data.
2. Watch the memory graph in the console while you replay representative traffic, including the largest request your app handles.
3. Leave it running for a day. A leak looks like a line that never comes back down.
4. Compare against the plan limit, not against a number from a blog.

Memory limits behave in a specific way on RE:NODE: a container that reaches its limit is stopped and restarted clean instead of swapping. The consequence on Bun and Deno is the same as on Node - a leak shows up as a restart every few hours. Three restarts in an hour raise a warning on the server page and open a ticket automatically. [Node memory limits explained](https://www.renode.space/blog/node-memory-limits-explained) covers heap flags in V8; they do not carry over to Bun, which has its own settings, so do not paste Node flags into a Bun start command and expect them to be honoured.

Startup time is the other claim. A TypeScript file that runs directly saves the compile step, and Bun's installer is quick, but on a hosted plan the number that matters is how long a deploy takes from push to answering, and that is dominated by dependency installation and your own boot work such as database connections and migrations. Measure the deploy, not the hello-world.

#### Process, environment and port handling

The same rules apply to all three runtimes, and they are the rules that cause most "it works locally" tickets.

**Read the port from the environment.** Hard-coding a port means the app binds somewhere nothing routes to. On RE:NODE an app plan has one port allocation, shown on the Network tab, and environment variables are set on the Startup tab. Use the variable name your plan shows rather than guessing it, and fall back to a default only for local runs.

**Bind to all interfaces.** `0.0.0.0` in `Bun.serve` and `Deno.serve`, and the equivalent in any framework. A server that logs "listening" on `127.0.0.1` and answers nothing is the most common failure, and it produces connection refused from the proxy with no line in your own log.

**Fail fast on missing configuration.** The pattern from the [Express production post](https://www.renode.space/blog/deploy-express-api-to-production) carries over unchanged:

```js
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) {
  console.error(`missing configuration: ${missing.join(", ")}`);
  process.exit(1);
}
```

Under Deno, replace `process.env[name]` with `Deno.env.get(name)`, and remember the `--allow-env` list has to include every name you read. Newer Deno versions also support `process.env` through the Node compatibility layer, but it is cleaner to pick one style per project. Where the secrets live is the subject of [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets).

**Handle SIGTERM.** A restart or a deploy sends a termination signal, and a server that ignores it is killed after a grace period, dropping requests in flight. In Bun and Node, `process.on("SIGTERM", ...)` works, and in Bun you stop accepting connections with `server.stop()`. In Deno, use `Deno.addSignalListener("SIGTERM", ...)` and `server.shutdown()` on the object `Deno.serve` returns. The general shape, including health endpoints, is in [graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks).

**Log to stdout.** The console shows it unfiltered and live. Structured JSON lines are worth the extra five minutes.

#### Running it on RE:NODE

RE:NODE has separate Bun and Deno lines in the app category, each starting at $4 a month for 1 GB of memory, 5 GB of disk and half a vCPU, with larger tiers above that. They sit beside Node.js, Python, Go, Java, Laravel and C#, and the panel is the same on all of them: console, file manager, SFTP, backups and the Startup tab. Plans and current prices are on the [app hosting](https://www.renode.space/app-hosting) page.

A few things are specific to these two lines:

- **A starting page.** Every runtime line except Node.js starts a new server with a small working page on its address until you deploy your own code, and it never overwrites your files. That is a quick way to confirm the address and the port allocation work before your code is involved. Node.js servers do not have this page.
- **Deploy from GitHub.** The Git deploy feature is available on all app lines: GitHub only, through a GitHub App with short-lived tokens so private repositories work. Two switches control it - pull on every start, and deploy on push, which restarts a running server and starts a stopped one - and each deploy leaves a record. The walkthrough for Node in [deploy a Node.js app from GitHub](https://www.renode.space/blog/deploy-a-node-app-from-github) maps closely onto the other lines.
- **The proxy slot.** App plans include a proxy slot. Point an A record at the address shown and the certificate is issued and renewed automatically inside a 21-day window; the real client address arrives in `X-Forwarded-For`. In `Bun.serve` and `Deno.serve` there is no `trust proxy` setting, so read the header yourself and, if you trust one hop, take the last entry. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) and [pointing a domain at your server](https://www.renode.space/blog/your-domain-and-its-certificate) cover the details.
- **No process manager needed.** The panel restarts a process that exits, so you do not need PM2 or a supervisor. [PM2 or a hosting panel](https://www.renode.space/blog/pm2-vs-a-hosting-panel) is the honest comparison.
- **Database slots.** App plans include two database slots, created in the panel with generated credentials, which you pass to the app as environment variables.

The things not to assume are the ones the panel does not say. This post does not tell you the exact start command a new Bun or Deno server uses or which variable name carries the port, because those are worth reading off your own Startup tab. Treat the hello-world page as proof of the address, then replace it.

The install and start commands for a typical deploy are short:

```bash
# Bun
bun install --frozen-lockfile && bun run server.ts

# Deno
deno task start
```

#### Choosing between Node, Bun and Deno

There is no universal answer, and the following is judgement rather than measurement.

Choose **Node.js** when you have an existing app with a pile of npm dependencies, when you rely on native addons, when the team already knows it, or when you want the least surprising behaviour. Almost every deployment problem you will meet already has a written answer for Node. The cost is a TypeScript compile step, which is small.

Choose **Bun** when you are starting a new TypeScript service, you want one tool for install, run and test, and your dependencies are plain JavaScript or well known to work on Bun. It is the closest to a drop-in for a Node project, because it reads `package.json` and `node_modules`, so a trial costs an afternoon rather than a rewrite. Run your tests under it before committing.

Choose **Deno** when the permission model appeals to you, when you want `deno check`, `deno fmt` and `deno lint` built in, or when you are writing against web-standard APIs and importing from JSR. It is a good fit for a small API with few dependencies. It is a less comfortable fit for a large Express codebase that was never written with permissions in mind.

If you are unsure, build the app on web-standard `Request` and `Response` with a small framework such as Hono, keep dependencies pure JavaScript, and you will be able to move between all three with a changed entry file. That portability is worth more than any claimed speed difference.

#### Troubleshooting

**The server starts and nothing answers.** Bind address or port. Check that the host is `0.0.0.0` and that the port is the one the plan allocates. Open the Network tab, compare, and look at the log line the app prints.

**`error: Cannot find module` on Bun after a deploy.** The install did not run, or ran with the wrong flags. Run `bun install --frozen-lockfile` in the start command and read the output in the console. If a package needs a postinstall step, add it to `trustedDependencies`.

**`error: Requires net access` or `Requires env access` on Deno.** A permission is missing. The error names the permission and, often, the exact host or variable. Add the narrow flag rather than `-A`.

**`error: Relative import path "x" not prefixed with / or ./ or ../`.** Deno needs either a relative path with an extension or a mapping in `imports` in `deno.json`. Add the mapping.

**Lockfile mismatch on install.** `bun install --frozen-lockfile` and Deno's `--frozen` fail when the manifest changed without the lockfile. Regenerate locally, commit, deploy. Do not drop the flag on the server.

**A package works locally and fails on the server.** Platform-specific optional dependencies, native binaries or a missing postinstall. Generate the lockfile on a Linux machine or in CI, and check the package's notes for your runtime.

**The memory graph climbs and then the server restarts.** A leak, an unbounded cache or a large payload held in memory. Read the memory section above, bound your caches, and check the console for the restart pattern. [Reading the console](https://www.renode.space/blog/reading-the-console) covers what to look for.

**A WebSocket connects locally and drops through the proxy.** Idle timeouts and upgrade handling in the proxy path. See [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy).

#### FAQ

##### Is Bun production ready?

It is used in production by many teams, and it is also younger than Node with a less complete compatibility record. Whether it is ready for your app depends on your dependencies. Run the test suite under it, load test the routes you care about, and keep the ability to fall back to Node for a release or two.

##### Can I run an existing Express app on Bun or Deno?

Usually, yes, through the Node compatibility layers, but treat it as a migration with a test pass rather than a flag flip. On Deno you add `npm:` specifiers or a `package.json`, plus the permissions. Watch native modules and anything that depends on Node internals.

##### Do I need a build step for TypeScript?

Not on Bun or Deno, which run `.ts` files directly. You still need a type check somewhere, because neither start command rejects a type error. Put `tsc --noEmit` or `deno check` in CI.

##### Which uses less memory, Bun or Deno?

It depends on the app, the versions and the load, and any single figure would be a guess. Measure your own app on the plan you intend to use and compare the graph against the limit.

##### What is the Bun lockfile called?

`bun.lock`, a text file, in current versions. Older versions wrote a binary `bun.lockb`. Commit the lockfile and install with `bun install --frozen-lockfile` on the server.

##### Do Deno permission flags work on a hosted plan?

Yes. They are flags on `deno run`, so they go in the start command or in a `deno.json` task. They limit your process; the hosting container is a separate layer of isolation.

Bun and Deno each have their own app line on RE:NODE from $4 a month, with a small working page on the address until you deploy, GitHub deploys that pull on start or on push, environment variables on the Startup tab and a proxy slot that issues and renews the certificate once your A record points at it.

[See Bun and Deno plans](https://www.renode.space/app-hosting#bun)

---

### Deploy a Go web service: net/http, chi and gin in production

Published 2026-10-14, updated 2026-10-14 by RE:NODE Team. https://www.renode.space/blog/deploy-a-go-web-service

A Go web service is the easiest thing in this category to run and the one with the fewest excuses when it misbehaves. The standard library already contains a production-grade HTTP server, the output is one statically linked binary, and an idle service sits in a few tens of megabytes. What goes wrong is almost always one of five small things: it binds to `127.0.0.1`, it hard-codes the port, it ignores `SIGTERM`, it has no timeouts, or the garbage collector is allowed to grow past the memory limit of the container.

This post covers all five, plus the parts specific to deploying on an app server that compiles your code itself: what `go.mod` has to say, what happens on every start, how a GitHub deploy fits in, what the proxy sends your handlers, which databases to point at, and what to read when the console shows an error.

#### What a Go service needs at run time

Almost nothing, which is the appeal. `go build` links your code, your dependencies and the Go runtime into one executable. There is no interpreter, no virtual environment and no `node_modules` on the server. The runtime brings its own scheduler and garbage collector, so a Go process is a single binary and a set of environment variables.

Two details are worth knowing before they surprise you:

- **Cgo changes the picture.** With `CGO_ENABLED=1` (the default when a C compiler is present) your binary may link dynamically against glibc, and packages such as `mattn/go-sqlite3` need a C toolchain at build time. With `CGO_ENABLED=0` the binary is fully static and has no such dependency. If a library you use has a pure Go alternative - `modernc.org/sqlite` instead of `go-sqlite3`, `pgx` instead of anything libpq-based - prefer it for a server you do not control.
- **Time zones and certificates.** A minimal environment may lack zone data and a CA bundle. If your program calls `time.LoadLocation("Europe/Berlin")` or makes outbound HTTPS requests, import `time/tzdata` to embed the zone database. Outbound TLS needs root certificates from the system, so check an outbound call works on the first deploy rather than assuming it.

Memory is the number to plan around. A small `net/http` service with a database pool commonly idles between 10 and 30 MB resident and grows with concurrent requests and buffers, not with code size. That is why the smallest app tier is a sensible home for most Go services, and why the build, not the run, is what occasionally needs headroom.

#### net/http, chi or gin

Start with the standard library. Since Go 1.22 the default `ServeMux` understands methods and path wildcards, which removed the main reason people reached for a router in the first place:

main.go:

```go
mux := http.NewServeMux()

mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("ok"))
})

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "user %s\n", id)
})
```

Use chi when you want middleware composition and route groups with the standard `http.Handler` interface preserved. Everything written for `net/http` works under chi unchanged, so you lose nothing by adopting it later:

```go
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer)

r.Route("/api", func(r chi.Router) {
    r.Get("/health", health)
    r.Get("/users/{id}", getUser)
})
```

Use gin when you want its binding and validation helpers and its ecosystem of ready-made middleware. It has its own context type, so handlers are tied to gin. In production, set `GIN_MODE=release` in the environment; the default debug mode prints every route at start-up and logs more than you want:

```env
GIN_MODE=release
```

None of the three is faster in a way that matters on a small plan. The difference between them is smaller than one database round trip. Pick on how you want to write handlers and middleware, and spend the saved effort on timeouts and shutdown, which no router does for you.

#### go.mod, go.sum and the build on start

On RE:NODE's Go line the server is built with `go build` on every start. That one sentence has consequences for how you lay out the project, so it is worth being deliberate.

First, `go.mod` is not decoration. It names the module, states the minimum Go version and lists every dependency with a version:

go.mod:

```text
module github.com/yourname/api

go 1.23

require (
    github.com/go-chi/chi/v5 v5.1.0
    github.com/jackc/pgx/v5 v5.7.1
)
```

Commit both `go.mod` and `go.sum`. The second holds checksums of every module version, and without it a build has to resolve and verify everything from scratch. Run `go mod tidy` before each commit so unused requirements are dropped and missing ones are added; a `go.mod` that disagrees with your imports is the commonest reason a build that works on your laptop fails on the server.

Second, the `go` line matters. If it names a version newer than the toolchain on the server, the Go tool may try to fetch the newer toolchain, or refuse to build, depending on the `toolchain` directive and the `GOTOOLCHAIN` setting. Do not set `go 1.25` because that is what you happen to have installed; set the lowest version your code genuinely needs. Check the Go version the line offers before you depend on a recent language feature.

Third, every start means a compile. That is a feature - the binary always matches the source that is on the server - and a cost, because a restart is not instant. A small service builds in seconds when the build cache is warm and in a minute or more when it is cold. Treat a restart as a deploy: it takes as long as the build plus the time your service needs to open its connections.

Fourth, compile errors become start errors. If the code does not build, the server does not start, and the compiler's message is in the console. That is a better failure than a half-working service, but it means a broken commit on a branch the server follows takes the service down. Run these locally before you push:

```bash
$ go mod tidy
$ go vet ./...
$ go test ./...
$ go build -o /tmp/api .
```

Keep the `main` package where the line expects it. The simplest layout is `main.go` at the repository root; if you prefer `cmd/api/main.go`, check on the Startup tab what the Go line builds and make sure it points at your main package before the first deploy.

#### Ports, binding and environment variables

There are two settings here, and both have defaults that are wrong inside a container.

The first is the bind address. `localhost:8080` and `127.0.0.1:8080` listen on the loopback interface only, which nothing outside the container can reach. Use `0.0.0.0`, or leave the host empty (`":8080"`), which listens on all interfaces.

The second is the port. Do not hard-code it. The allocated port arrives as an environment variable, and the panel's Startup tab shows the variables your server has. Read the name it shows there; the example below tries `PORT` first and falls back to `SERVER_PORT`, then to a development default, so the same binary runs on your laptop and on the server:

main.go:

```go
func port() string {
    for _, name := range []string{"PORT", "SERVER_PORT"} {
        if v := os.Getenv(name); v != "" {
            return v
        }
    }
    return "8080"
}

func requireEnv(name string) string {
    v := os.Getenv(name)
    if v == "" {
        log.Fatalf("missing required environment variable %s", name)
    }
    return v
}
```

Read every variable once, during start-up, and fail immediately if a required one is missing. A service that discovers an absent `DATABASE_URL` at three in the morning on a rare code path is harder to debug than one that refuses to start. The Startup tab is also where you add your own variables - database URL, session secret, API keys - so they stay out of the repository. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers what belongs there and what does not.

A short configuration struct keeps this tidy and gives you one place to log the effective settings (minus secrets) at start:

```go
type Config struct {
    Addr        string
    DatabaseURL string
    Env         string
}

func load() Config {
    return Config{
        Addr:        ":" + port(),
        DatabaseURL: requireEnv("DATABASE_URL"),
        Env:         os.Getenv("APP_ENV"),
    }
}
```

#### Timeouts, logging and graceful shutdown

The zero-value `http.Server` has no timeouts at all. That is fine on a laptop and a problem in production, because a client that opens a connection and sends nothing holds a goroutine and a file descriptor indefinitely. Use an explicit server:

main.go:

```go
srv := &http.Server{
    Addr:              cfg.Addr,
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}
```

| Field | What it limits | Typical value |
| --- | --- | --- |
| `ReadHeaderTimeout` | Time to read request headers | 5 s |
| `ReadTimeout` | Whole request including body | 15 s |
| `WriteTimeout` | Time to write the response | 30 s |
| `IdleTimeout` | Keep-alive connections between requests | 60 s |

`WriteTimeout` is the one that bites. It covers the whole response, so a streaming endpoint, a large download or a server-sent events stream will be cut off when it fires. For those routes, clear the deadline per request with `http.NewResponseController(w).SetWriteDeadline(time.Time{})`, or run them on a separate server with different limits.

Log to standard output so the panel console has it. Since Go 1.21 the standard library has structured logging in `log/slog`:

```go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("listening", "addr", cfg.Addr)
```

JSON lines are easy to search and awkward to read live, so switch to `slog.NewTextHandler` when `APP_ENV` is not production.

Now shutdown. Stopping or restarting a server sends `SIGTERM`. If your program does not handle it, the process is killed after the grace period and whatever was in flight is dropped - half-written responses, buffered writes, jobs mid-run. Go makes handling it short:

main.go:

```go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}()

<-ctx.Done()
slog.Info("shutting down")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
    slog.Error("shutdown", "err", err)
}
db.Close()
```

`Shutdown` stops accepting new connections, waits for active requests to finish, and returns when they have or when the context expires. Two traps: `ListenAndServe` returns `http.ErrServerClosed` immediately when you call `Shutdown`, which is not an error and must not be logged as one; and `Shutdown` does not wait for hijacked connections such as websockets, so close those yourself. Keep the shutdown timeout shorter than the time the platform gives you, ten seconds being a reasonable ceiling. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers the health endpoint side, which for Go is a handler returning a constant.

#### Deploying from GitHub

On the app lines, deploys come from GitHub only, through a GitHub App that uses short-lived tokens, so private repositories work without you pasting a personal access token anywhere. There are two switches, and they are independent:

- **Pull on every start.** Each time the server starts, it fetches the latest commit of the branch it follows, then builds it.
- **Deploy on push.** A push to the branch restarts a running server and starts a stopped one. Every deploy is recorded as one entry.

Combine that with the build-on-start behaviour and the deploy pipeline is short:

```diagram
title: From a push to a running Go service
repo = GitHub repo | go.mod, go.sum, source
pull = Pull on start | latest commit
build = go build | on every start
bin = Go process | listens on the port
proxy = Proxy slot | TLS, X-Forwarded-For
users = Clients
repo -> pull : push
pull -> build
build -> bin
users -> proxy : HTTPS
proxy -> bin : HTTP
```

Two habits keep this safe. Deploy from a branch that only receives code that already passes tests, because deploy on push will restart production on whatever lands there. And keep secrets out of the repository, since the build reads the same tree you pushed. If a push fails to build, the compiler output is in the console, and the fix is another push.

The first time you push to an empty server, remember that every runtime line except Node.js starts with a small working page on its address until you deploy code, and that page never overwrites your files. If you see it after a deploy, the deploy did not take effect: look at the deploy record and the console before blaming the proxy.

#### Reverse proxy, domain and X-Forwarded-For

Terminating TLS inside the Go program is possible with `ListenAndServeTLS` or `autocert`, and on a hosting plan it is usually the wrong choice: you take on certificate renewal, the HTTP-to-HTTPS redirect and the reload when a certificate changes. Serve plain HTTP on your allocated port and let the proxy do the rest.

App plans include a reverse-proxy slot. Point an A record at the address shown for the slot and the certificate is issued and renewed automatically, inside a 21-day window. Allow time for the record to propagate before you test; a certificate cannot be issued for a name that does not yet resolve to the address. [Connecting a domain to a game server](https://www.renode.space/blog/connecting-a-domain-to-a-game-server) walks through the record itself, and the same steps apply to an app. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the header chain in more depth.

What this means for your handlers:

- **`r.RemoteAddr` is the proxy, not the visitor.** The real client address arrives in the `X-Forwarded-For` header. If you log `r.RemoteAddr` or rate-limit on it, you are logging and limiting the proxy, and every visitor shares one bucket.
- **Take the right element.** `X-Forwarded-For` is a comma-separated list. Anything a client sent themselves appears at the left; the address your proxy added is at the right. Trusting the leftmost value lets any client claim to be anyone. Because the proxy here is the only way in, trusting the header at all is reasonable, but take the last entry the proxy appended rather than the first.
- **Use middleware once, early.** chi ships `middleware.RealIP`, which rewrites `r.RemoteAddr` from the forwarding headers. Install it only when the proxy is guaranteed to be in front, since it will believe whatever the headers say.
- **Build absolute URLs from `X-Forwarded-Proto`.** Redirects, email links and OAuth callbacks need the public scheme. Reading `r.TLS == nil` always says plain HTTP behind a proxy, and the result is an `http://` redirect loop.

```go
func clientIP(r *http.Request) string {
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        parts := strings.Split(xff, ",")
        return strings.TrimSpace(parts[len(parts)-1])
    }
    host, _, _ := net.SplitHostPort(r.RemoteAddr)
    return host
}
```

Websockets work through the proxy but need heartbeats shorter than any idle timeout; [websockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) covers the details, and it applies unchanged to `gorilla/websocket` and `nhooyr.io/websocket`.

#### Databases: Postgres, MySQL and Valkey

A Go service on its own is stateless, which is what you want. State goes into a database, and there are two ways to get one.

Every app plan comes with database slots, created in the panel with a generated host, user and password. For something larger or separate, there are [database hosting](https://www.renode.space/database-hosting) plans for PostgreSQL, MySQL 8.4 LTS, MongoDB, SQL Server Express and Valkey. Those are reached on the plan's own host and port with credentials generated per server. Put the resulting connection string in an environment variable on the Startup tab, never in the code.

For PostgreSQL, `pgx` is the driver to use directly or through `database/sql`. Its pool is `pgxpool`:

```go
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
    log.Fatal(err)
}
defer pool.Close()
```

For MySQL, the standard choice is `go-sql-driver/mysql` under `database/sql`. Put `parseTime=true` in the DSN or you will get byte slices instead of `time.Time` values, and set the charset explicitly:

```text
user:pass@tcp(host:3306)/appdb?parseTime=true&charset=utf8mb4&loc=UTC
```

`database/sql` pools by default and with no limits, which is the part to fix. Left alone it opens as many connections as concurrent queries demand, and a traffic spike turns into a connection limit error on the database side:

```go
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
```

A Go process has one pool, not one per worker, so ten is generous for a service on one or two cores. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) has the arithmetic for the database side.

For caching, sessions and queues, use Valkey. It is the open-source fork of Redis and speaks the Redis protocol, so `go-redis` or `rueidis` connect to it unchanged. The instance is password-protected and saves to disk, using both AOF and snapshots. [Valkey getting started](https://www.renode.space/blog/valkey-getting-started) covers connecting, and [job queues with Valkey](https://www.renode.space/blog/job-queues-with-valkey) is the natural next step.

Run migrations deliberately. Applying them on every start from `main` is convenient and dangerous, because a restart loop will retry a half-applied migration. A separate command, or a tool such as `golang-migrate` or `goose` run by hand, is safer for anything beyond a toy.

#### Memory: GOMEMLIMIT, GOGC and build memory

At the memory limit the kernel stops the container and it restarts clean, rather than swapping. For a Go service this is the main operational risk, because Go's garbage collector by default knows nothing about your limit. With `GOGC=100` it lets the heap grow to roughly twice the live data before collecting, so a service with 300 MB of live data can reach 600 MB without anything being wrong.

Two settings fix that, and they are environment variables on the Startup tab:

| Variable | Default | What it does |
| --- | --- | --- |
| `GOGC` | `100` | Heap growth before a collection, as a percentage |
| `GOMEMLIMIT` | none | Soft ceiling for the Go runtime's total memory |
| `GOMAXPROCS` | logical CPUs | Threads that run Go code at once |

`GOMEMLIMIT` takes a size with a unit suffix, for example `GOMEMLIMIT=800MiB`. It is a soft limit: as the runtime approaches it, the collector works harder to stay under, trading CPU for memory. Set it below the container limit, not at it, because the Go heap is not the only thing using memory - stacks, the runtime itself and cgo allocations count too. Roughly 10 to 20 percent below the plan's memory is a sensible start. On a 1 GB plan that is `GOMEMLIMIT=800MiB`.

A soft limit is not a cure for a leak. If live data really exceeds the limit, the collector runs almost continuously, CPU climbs, latency rises and you get a death spiral rather than a clean failure. If the graph on the console shows memory rising steadily after traffic has levelled off, find the leak (an unbounded map, a goroutine per request that never exits, a response body never closed) rather than raising the limit.

`GOGC` pairs with it. Lowering it, to `50`, collects more often and uses less memory at a cost in CPU. Raising it, to `200`, does the opposite. With `GOMEMLIMIT` set you can even set `GOGC=off` and let the limit alone drive collection, but that is for services whose live heap is small and well understood. Leave `GOGC` at its default until a graph gives you a reason.

CPU is a hard throttle to the share you bought: a plan with 0.5 vCPU runs your threads at that rate whatever the runtime thinks. Before Go 1.25, `GOMAXPROCS` defaulted to the number of logical CPUs of the machine rather than the quota, which can mean more scheduler threads than the container can use and stalls when it is throttled; later versions take the container quota into account. If you build with an older Go, set `GOMAXPROCS` to the whole number of cores your plan gives you, or import `go.uber.org/automaxprocs`.

Build memory is the separate question. The Go compiler is far lighter than a Rust release build, but it is not free, and the build happens inside the same container on every start. Figures from experience, not promises:

| Project | Cold build | Peak build memory |
| --- | --- | --- |
| Small API, a dozen dependencies | 20-60 seconds | 300-500 MB |
| Typical service, a few dozen modules | 1-3 minutes | 500-900 MB |
| Large tree, generated code, big protobufs | 3-8 minutes | 1 GB or more |

On the smallest tier a typical service builds, but a large dependency tree can reach the limit and be stopped mid-compile, which shows up as a restart rather than a slow build. If that happens, `go build -p 1` (or `GOFLAGS=-p=1`) compiles one package at a time and cuts peak memory at the cost of time. Beyond that, trim what you import: a dependency that pulls in a cloud SDK for one function is the usual culprit. For release builds, `-ldflags="-s -w"` strips symbol and debug information, shrinking the binary by a quarter or more without changing run-time memory.

#### What goes wrong

**The page loads on your laptop and times out on the server.** Bound to `localhost`. Bind `:PORT` or `0.0.0.0:PORT`.

**`listen tcp :8080: bind: address already in use`.** Either a second copy of your program is running, or you hard-coded a port that is not the one allocated. Read the environment variable.

**The server never gets past the first start and the console shows a compiler error.** The build failed. Reproduce locally with `go build ./...` on a clean checkout; a missing `go.sum` entry, an unused import or an uncommitted file are the usual causes.

**`go: updates to go.mod needed; to update it: go mod tidy`.** Your imports and `go.mod` disagree. Run `go mod tidy` locally and commit both files.

**`go.mod requires go >= 1.24 (running go 1.23)`.** The `go` line is newer than the toolchain. Lower it to the version you really need, or switch the line to a Go version that satisfies it.

**The build is killed with no Go error.** Out of memory in the compiler. Try `go build -p 1`, remove heavy dependencies, or move up a tier for the plan you build on.

**Restarts every few minutes, memory graph climbing.** Hitting the limit. Set `GOMEMLIMIT`, then look for the leak. Remember that three restarts in an hour raise a warning on the server page, and six lead to suspension.

**`dial tcp: lookup db: no such host`.** The database host is wrong for this environment. Use the host and port the panel or database plan gave you, not a name from your local compose file.

**Every visitor has the same IP in the logs.** You are reading `r.RemoteAddr`. Read `X-Forwarded-For` as above.

**`x509: certificate signed by unknown authority` on outbound calls.** The environment has no usable CA bundle, or the server you call uses a private CA. Test an outbound request in the first deploy so you meet this early.

**Requests hang, then fail after a long wait.** Handlers that call out without a deadline. Use `http.Client{Timeout: ...}` and pass `r.Context()` into database and HTTP calls so a cancelled request stops its work.

#### FAQ

##### Which Go version should I target?

The lowest your code genuinely needs, written in the `go` line of `go.mod`. Anything you write with 1.22 or newer gets the method-aware `ServeMux`, and 1.21 added `log/slog`. Check the version the Go line provides and keep your `go` directive at or below it.

##### Do I need chi or gin?

No. The standard library is enough for most APIs, and handlers written for it move to chi without changes. Add a router when you want route groups and middleware chaining, and gin when you want its request binding and validation.

##### How do I get the real client IP?

Read `X-Forwarded-For`, which the proxy sets, and use the last entry it appended rather than the first. `r.RemoteAddr` is the proxy's address and will be the same for every visitor.

##### Why does a restart take longer than for a Node or Python app?

The server runs `go build` on every start, so a restart includes a compile. It is quick when little has changed and slower when it is cold. Keeping dependencies lean keeps it short.

##### Can I run migrations on start?

You can, and for a toy it is fine. For anything real run migrations as a separate step, because a service stuck in a restart loop will reattempt the same half-applied change.

##### Should I set GOMEMLIMIT?

Yes, if your service holds a meaningful amount of heap. Set it 10 to 20 percent below the plan's memory so the collector tightens up before the container is stopped. It stops the garbage collector from treating the whole machine as its own.

Go plans start at $4 a month and run to 8 GB. The server builds your code with `go build` on every start, deploys come from GitHub on start or on push, and each plan has a proxy slot with a certificate issued and renewed for you. Database slots and backup slots are included.

[See Go plans](https://www.renode.space/app-hosting#go)

---

### Deploy a Spring Boot app to production on a Java server

Published 2026-10-14, updated 2026-10-14 by RE:NODE Team. https://www.renode.space/blog/deploy-spring-boot-app-to-production

A Spring Boot application deploys as one file: a fat jar that carries your code, its dependencies and an embedded web server. The server needs a Java runtime of the right version, the jar, a port to listen on, and a heap that fits inside the memory limit you pay for. The last of those is where nearly every failed Spring Boot deployment goes wrong, because the JVM is happy to ask for more memory than a small plan has, and a container that is stopped for exceeding its limit looks, from the outside, like a crash with no explanation.

This post covers the whole path for Spring Boot, with notes where Quarkus and Micronaut differ: building the jar with Maven or Gradle, choosing a Java version, binding to the right port, sizing the heap against the plan, keeping configuration out of the jar, connecting a PostgreSQL or MySQL database through HikariCP, exposing a health endpoint, running behind a proxy so client addresses survive, and the errors you will see when something is off.

#### What the server needs to run a fat jar

A fat jar (also called an uber jar or executable jar) is a normal jar with the dependency jars nested inside it and a launcher class that knows how to read them. Spring Boot's Maven and Gradle plugins produce it, and `java -jar app.jar` is the entire run command. There is no application server to install, no WAR to drop into a Tomcat, and no classpath to assemble by hand.

What that means for hosting is short:

- **A JRE, not a JDK.** You compile on your own machine or in CI. The server only runs the result, so the runtime is all it needs.
- **The right major version.** Spring Boot 3 requires Java 17 or newer. Spring Boot 2.7 and older run on Java 8 to 17 but are out of open-source support. RE:NODE's Java app line runs any jar on Java 17, 21 or 25, so a Boot 3 application fits on all three, and Boot 2.x applications are only reasonable on 17.
- **A single listening port.** One embedded server, one port. Actuator can be moved to a second port, but on a plan with one allocation you will keep it on the main one and protect it differently.
- **Memory for more than the heap.** The JVM's footprint is the heap plus metaspace, thread stacks, the code cache, direct buffers and garbage collector structures. A heap of 512 MB commonly means a process of 700 to 800 MB. Most of the sizing section below exists to make that sentence concrete.

Pick the Java version by what you compile for, not by what is newest. Compile with `--release 17` or the matching Maven and Gradle setting, and a jar built for 17 runs unchanged on 21 and 25. Going the other way fails with `UnsupportedClassVersionError`, covered in the troubleshooting section. If your dependencies are current, Java 21 is the sensible default: virtual threads are available, the garbage collectors are better tuned for containers, and it has a long support horizon. [Java 25](https://www.renode.space/app-hosting#java) is the newer line and worth testing if you want to be on the latest long-term release.

#### Building the jar with Maven or Gradle

Build in CI or on your own machine, run the tests there, and ship the artefact. The build is not something to do on a small production plan, for the same reason Rust builds are not: Maven or Gradle with a warm dependency cache is happy, a cold build on a 1 GB container is slow and can reach the memory limit.

For Maven, the Spring Boot parent or the `spring-boot-maven-plugin` makes the jar executable:

```bash
$ ./mvnw -B clean package -DskipTests=false
$ ls target/*.jar
target/shop-api-1.4.0.jar  target/shop-api-1.4.0.jar.original
```

The `.original` file is the thin jar before repackaging. Deploy the other one. For Gradle:

```bash
$ ./gradlew clean bootJar
$ ls build/libs/
shop-api-1.4.0.jar
```

`bootJar` is the task that produces the executable archive; plain `jar` produces a thin one that will not start. Gradle builds with the `org.springframework.boot` plugin applied also produce a `-plain.jar`, which is likewise not what you want.

Three details save a deployment later:

1. **Give the jar a stable name.** With Maven set `<finalName>app</finalName>` inside `<build>`; with Gradle set `bootJar { archiveFileName = 'app.jar' }`. A start command that points at `app.jar` does not need editing on every release.
2. **Build info for the health endpoint.** `spring-boot-maven-plugin` has a `build-info` goal and the Gradle plugin has `springBoot { buildInfo() }`. With either, Actuator's `/actuator/info` can report the version that is actually running, which answers the first question in any incident.
3. **Check the manifest.** `unzip -p app.jar META-INF/MANIFEST.MF` should show a `Main-Class` of `org.springframework.boot.loader...` and a `Start-Class` of your application. If it does not, the repackage step did not run.

Quarkus and Micronaut differ in the artefact. Quarkus's default `quarkus-app` layout is a directory, not a single file, with `quarkus-run.jar` and a `lib` folder next to it. If you want one file, set `quarkus.package.jar.type=uber-jar` and build with `./mvnw package`, and run `java -jar target/*-runner.jar`. Micronaut's `shadowJar` Gradle task and its Maven Shade configuration produce a single runnable jar. Everything below about heap, ports and databases applies to both, with different property names.

#### Uploading and starting it

The way a jar reaches the server decides how much of the rest you can automate. There are three routes: upload it by hand through the file manager or SFTP; pull a repository from GitHub and build there; or build in CI and upload only the artefact.

On RE:NODE, each app server has the file manager, which unpacks archives in place, and SFTP with credentials per server. Both are fine for a jar. The panel also supports deploys from GitHub through a GitHub App, which works for private repositories, with two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. That is a source-to-server path, so it only helps if the server itself is allowed to build; for a jar built in CI, uploading the artefact is simpler and keeps the build off the production memory budget. If you use the git route, commit the built jar or use a branch that holds only release artefacts, rather than expecting the plan to run Maven.

When a Java server is created, it starts with a small working page on its address until you deploy your own code, and that page never overwrites your files. Once your jar is in place and the start settings point at it, the page is replaced by your application. The Startup tab is where environment variables are set, and the console shows live output, with start, stop, restart and kill buttons.

```diagram
title: From a build to a running Spring Boot app
dev = Your build | mvn package
jar = app.jar | fat jar
jvm = JVM | -Xmx, MaxRAMPercentage
db = Database | HikariCP pool
proxy = Proxy slot | TLS, X-Forwarded-For
users = Clients
dev -> jar : upload
jar -> jvm : java -jar
jvm -> db : JDBC
users -> proxy : HTTPS
proxy -> jvm : HTTP
```

#### Ports: server.port and SERVER_PORT

The listening port is the first thing that breaks. Spring Boot defaults to `8080`. On a hosting plan you are given an allocated port, and your application has to listen on that one, not on whatever you used locally.

Spring Boot reads the same property from several places, and the order matters. Command-line arguments win over OS environment variables, which win over `application.properties` inside the jar. The environment variable `SERVER_PORT` is the relaxed-binding form of `server.port`, so setting it in the environment needs no code at all:

environment:

```env
SERVER_PORT=25565
SPRING_PROFILES_ACTIVE=prod
```

The number above is a placeholder; use the port shown for your server. On panels derived from Pterodactyl the allocation is commonly exposed as `SERVER_PORT`, so check the Startup tab and the Network tab for what your server actually provides before assuming. If the variable already exists and holds the allocation, you do not set anything: Spring will pick it up as `server.port` the moment it starts.

If you prefer to keep it in the file, a reference with a default is a good pattern, because it works locally and in production:

application.properties:

```properties
server.port=${SERVER_PORT:8080}
```

The address is the other half. Spring's embedded server listens on all interfaces by default, so you do not need `server.address`. Do not set it to `127.0.0.1` for production - that makes the app reachable only from inside its own container, and the proxy will report a bad gateway. Quarkus binds to `localhost` by default in some modes and needs `quarkus.http.host=0.0.0.0` and `quarkus.http.port=${SERVER_PORT:8080}`. Micronaut reads `micronaut.server.port`, and honours the `MICRONAUT_SERVER_PORT` environment variable.

#### Sizing the JVM against the plan's memory limit

This is the section that matters most. A container has a hard memory limit, and the JVM, if you let it, will decide for itself how large to make the heap. Modern JVMs read the container limit through cgroups and default the maximum heap to one quarter of it. That is a safe default, and a wasteful one: on a 2 GB plan it gives you a 512 MB heap and leaves 1.5 GB idle.

What happens at the limit is a platform matter. On RE:NODE, when a process reaches the memory limit, the kernel stops the container and it restarts clean instead of swapping. Upstream Pterodactyl does the opposite and lets the machine swap. Clean stopping is better for a production service, because a swapping JVM produces garbage collection pauses of seconds and then dies slowly. It also means that if you set the heap too large, the symptom is a restart with nothing in the application log: the process was killed from outside, and the JVM never got a chance to write an `OutOfMemoryError`.

There are two different failures to tell apart:

- **`java.lang.OutOfMemoryError: Java heap space`.** The heap is too small for your data. The JVM logs it, and the heap needs to be larger, or the application has a leak.
- **A restart with no Java error.** The whole process went over the container limit. The heap is too large for the plan, or non-heap memory has grown. The heap needs to be smaller.

Three ways to set the heap, in order of preference:

```bash
# 1. A percentage of the container limit (recommended)
$ java -XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=40.0 -jar app.jar

# 2. An absolute ceiling
$ java -Xms256m -Xmx768m -jar app.jar

# 3. Environment only, no command change
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError"
```

`MaxRAMPercentage` scales when you change plan, which is the main reason to prefer it: move from 2 GB to 4 GB and the heap follows, with no edit. `-Xmx` is exact and is the right choice when you have measured and want a fixed value. When both are present, `-Xmx` wins. `JAVA_TOOL_OPTIONS` is read by every JVM that starts, which is useful when you cannot edit the start command; the JVM prints `Picked up JAVA_TOOL_OPTIONS: ...` at the top of the console so you can confirm it took effect. Note that it also applies to any other Java process that starts in the same environment.

The percentage to choose depends on the plan, because the fixed overhead is a bigger share of a small one:

| Plan memory | Heap setting | Reason |
| --- | --- | --- |
| 1 GB | `-Xmx384m` to `-Xmx512m` | 500 MB or more goes to non-heap |
| 2 GB | `MaxRAMPercentage=60` | About 1.2 GB heap, 800 MB for the rest |
| 4 GB | `MaxRAMPercentage=65` | Non-heap overhead is a smaller share |
| 6 GB and up | `MaxRAMPercentage=70` | Leave room for direct buffers and spikes |

These are starting points, not measurements of your app. A service that holds large caches or does a lot of NIO will want more headroom. A tiny API with a modest dependency tree runs in less. Watch the memory graph in the console against the limit for a day of real traffic and adjust.

A few more flags are worth knowing:

| Flag | Effect |
| --- | --- |
| `-XX:+ExitOnOutOfMemoryError` | Exit on a heap OOM so the platform restarts it clean |
| `-XX:MaxMetaspaceSize=256m` | Cap class metadata, which can leak on redeploys |
| `-Xss512k` | Smaller thread stacks, if you run hundreds of threads |
| `-XX:+UseSerialGC` | On 1 CPU or under 1.5 GB, the lowest overhead collector |

`ExitOnOutOfMemoryError` is the one most people skip and should not. A JVM that has thrown a heap OOM in one thread is often alive but wrecked, serving some requests and failing others. Exiting lets the restart behaviour described above give you a clean process. Be aware that restarts are watched: the crash detection polls every two minutes for uptime that went backwards or a server that went offline, restarts you ask for are not counted, three restarts in an hour raises a warning and an automatic ticket, and six means suspension. A service that restarts in a loop because the heap is too large is exactly what that is looking for, so fix sizing before you leave it running.

The collector matters on small plans. Java picks G1 on machines with two or more CPUs and at least 1792 MB, and falls back to the serial collector otherwise. G1 is a good default at 2 GB and up. Do not tune it unless a graph tells you to. [CPU vs RAM for game servers](https://www.renode.space/blog/cpu-vs-ram-for-game-servers) makes the general point about measuring which resource is binding before buying more of the other, and it applies unchanged to a JVM.

CPU is a hard throttle to the share you bought. The JVM sizes its own thread pools from the CPU count it sees, which is derived from the container quota and rounded up. A service on a half-core share can believe it has one, so the compiler threads and GC threads compete with request threads. If startup is slow or latency spikes in bursts, `-XX:ActiveProcessorCount=1` pins the JVM's view of the machine and is worth trying on the smallest tiers.

#### Configuration: application.properties and environment variables

The jar is the same everywhere. What changes between your laptop and production is configuration, and none of it should live inside the jar. Spring Boot's externalised configuration gives you a clear order, from lowest to highest priority:

1. `application.properties` or `application.yml` packaged inside the jar.
2. The same files beside the jar, in the working directory or in a `config/` folder next to it.
3. Profile-specific files such as `application-prod.properties`.
4. OS environment variables.
5. Java system properties (`-Dkey=value`) and command-line arguments (`--key=value`).

The consequence is useful: ship defaults inside the jar, and override only what differs in production using the environment. The binding rule for environment variables is mechanical - upper case, dots and dashes become underscores:

| Property | Environment variable |
| --- | --- |
| `server.port` | `SERVER_PORT` |
| `spring.datasource.url` | `SPRING_DATASOURCE_URL` |
| `spring.datasource.hikari.maximum-pool-size` | `SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE` |
| `management.endpoint.health.show-details` | `MANAGEMENT_ENDPOINT_HEALTH_SHOWDETAILS` |

Notice the pool size row: the dash in `maximum-pool-size` is dropped rather than converted, and the name runs together. Underscores inside the key name are the usual cause of an environment variable that appears to do nothing. When in doubt, hit `/actuator/env` in a test environment, or set `--debug` once and read the condition report in the console.

Keep secrets out of the jar and out of the repository. Database passwords, API keys and signing secrets belong in environment variables, which on RE:NODE are set on the Startup tab. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers where each kind belongs and what not to log. A config file that sits beside the jar is the alternative for values you want to edit without a restart cycle in mind; the file manager's editor with syntax highlighting makes that comfortable. Spring does not reload `application.properties` while running, so a change always needs a restart.

A profile switch ties it together. Keep `application.properties` for the shared defaults and `application-prod.properties` for what production changes, and activate it with `SPRING_PROFILES_ACTIVE=prod`:

application-prod.properties:

```properties
server.port=${SERVER_PORT:8080}
server.forward-headers-strategy=native
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
spring.main.lazy-initialization=false
logging.level.root=INFO
```

`spring.jpa.open-in-view=false` stops Spring holding a database connection for the whole of every web request. It is on by default and logs a warning at startup because it is a common source of pool exhaustion. `ddl-auto=validate` makes the application check the schema rather than change it; production schemas belong to a migration tool such as Flyway or Liquibase, not to Hibernate's guesses.

#### Connecting a PostgreSQL or MySQL database

Spring Boot auto-configures a `DataSource` from three properties and a driver on the classpath. The driver is a dependency: `org.postgresql:postgresql` for PostgreSQL, `com.mysql:mysql-connector-j` for MySQL. The URL carries the host, port and database name:

environment:

```env
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/shop?sslmode=prefer
SPRING_DATASOURCE_USERNAME=shop_app
SPRING_DATASOURCE_PASSWORD=change-me
```

For MySQL the URL starts `jdbc:mysql://host:3306/dbname`, and the driver class is found automatically from the URL. The host, user and password come from wherever the database lives. Each app server has database slots, created in the panel with a generated host, user and password. For a standalone database there is [database hosting](https://www.renode.space/database-hosting) with PostgreSQL and MySQL 8.4 LTS, whose credentials are generated per server and reached on the plan's own host and port. Copy the values into the three variables above, not into the jar. The [PostgreSQL remote connection guide](https://www.renode.space/blog/postgresql-remote-connections-getting-started) and the [MySQL one](https://www.renode.space/blog/mysql-remote-connections-getting-started) go through connection strings in more detail.

##### HikariCP pool sizing

Spring Boot uses HikariCP by default. It keeps a pool of open connections and hands them to requests. The default `maximum-pool-size` is 10, and `minimum-idle` defaults to the same, so a Boot application opens ten connections at start and holds them. That is a lot for a small database.

application-prod.properties:

```properties
spring.datasource.hikari.maximum-pool-size=6
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1500000
spring.datasource.hikari.keepalive-time=120000
```

The values to understand:

- **`maximum-pool-size`** is the hard ceiling on connections from this process. Bigger is not faster. A pool of 6 serving a service on one or two cores handles far more traffic than most people expect, because a query that takes 5 ms lets one connection serve 200 requests a second. If requests queue for connections, look for slow queries before raising the number.
- **`connection-timeout`** is how long a request waits for a free connection before failing, in milliseconds. The default of 30 seconds is too long to tolerate in a web request; 10 seconds or less fails visibly and quickly.
- **`max-lifetime`** must be shorter than any connection time limit on the database or a network device between you and it. Hikari retires a connection before the server can close it underneath you. The default is 30 minutes, and a value a few minutes under the real limit is correct.
- **`keepalive-time`** pings idle connections, which stops stateful firewalls dropping them silently.

Count connections across everything that connects. If you run two instances of the app, or a migration job beside it, the sums add up against the database's own connection limit. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) has that arithmetic, and the [MySQL connection limits](https://www.renode.space/blog/mysql-connection-limits-and-pooling) post covers the server side.

Add Flyway or Liquibase and put the migrations in `src/main/resources/db/migration`. The app then brings its own schema up to date during start, before it serves traffic. That makes startup slightly longer and deploys repeatable, and it is the main reason to set `ddl-auto=validate`.

#### Health checks with Actuator

Add `spring-boot-starter-actuator` and expose only what you need. By default only `health` is exposed over HTTP, which is the correct instinct to keep:

application-prod.properties:

```properties
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
management.endpoint.health.show-details=never
management.health.db.enabled=true
```

With probes enabled, Spring exposes `/actuator/health/liveness` and `/actuator/health/readiness` in addition to `/actuator/health`. Liveness answers "is the process in a state where it can continue" and should never depend on the database. Readiness answers "should this instance get traffic", and a database that is down belongs there. The overall `/actuator/health` aggregates the registered indicators, including the datasource, disk space and anything you add, and returns `{"status":"UP"}` with an HTTP 200, or 503 when something is down.

Test it from the console or your own machine:

```bash
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/actuator/health
200
```

Two cautions. Never expose `env`, `heapdump`, `beans` or `mappings` on a public address: they leak configuration and, in the case of `heapdump`, memory contents including secrets. If you need them, put them on a separate management port with `management.server.port` and do not forward it, or protect them with Spring Security. And keep the health response cheap: an indicator that calls three third-party services turns every monitor ping into load. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) goes through what a good check does and does not do, and `server.shutdown=graceful` above is the shutdown half of the same story, letting in-flight requests finish when you press restart.

#### Behind the proxy: TLS and X-Forwarded-For

Do not terminate TLS inside the application. App plans include a reverse-proxy slot: point an A record at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window. Your application serves plain HTTP on its allocated port, and the proxy sends the real client address in `X-Forwarded-For`. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the full chain.

Spring Boot does not trust those headers unless you tell it to. Without that, `request.getRemoteAddr()` returns the proxy, every redirect is built with `http://`, and `request.isSecure()` is false. The fix is one property:

application-prod.properties:

```properties
server.forward-headers-strategy=native
```

The two useful values are `native` and `framework`. `native` lets the embedded server (Tomcat, Jetty or Undertow) process `X-Forwarded-*` itself, which is the right answer for a plain servlet application. `framework` uses Spring's own `ForwardedHeaderFilter`, which is the choice for WebFlux or when you want Spring to do the work. Do not set both. With either in place, `getRemoteAddr()` yields the client, redirects and `Location` headers use `https`, and generated absolute URLs in links and OAuth callbacks are correct.

Tomcat only honours the header from a trusted proxy. The relevant property is `server.tomcat.remoteip.internal-proxies`, whose default is a regular expression covering private and loopback ranges. If the proxy reaches your container from an address outside that range, the header is ignored and nothing in the log tells you why. Test with a request and print `X-Forwarded-For` and the resolved remote address from a debug endpoint.

For Quarkus the equivalent is `quarkus.http.proxy.proxy-address-forwarding=true`, with `quarkus.http.proxy.allow-x-forwarded=true` on current versions. For Micronaut, resolve the client with `HttpClientAddressResolver`, which reads `X-Forwarded-For` for you. WebSockets and server-sent events need the upgrade handled and an idle timeout longer than your heartbeat; [Websockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) covers it, and it applies to Spring's WebSocket and `SseEmitter` without change.

#### Startup time and how to shorten it

A Spring Boot application with Hibernate, security and a dozen starters commonly takes 8 to 25 seconds to be ready on a fractional core, and longer on the first start after a deploy, since the JVM has to load and verify every class. That is normal, and it matters for two reasons: monitors that check too early report an outage, and restarts are slow.

What actually moves the number:

- **More CPU share during startup.** Startup is CPU-bound and mostly single-threaded. A half-core plan takes noticeably longer than a full core, so the cheapest speedup is a larger tier, not a flag.
- **Fewer beans and fewer auto-configurations.** Remove starters you do not use. `--debug` prints the list of auto-configurations that matched, and every one costs time.
- **`-XX:TieredStopAtLevel=1`.** Stops the JIT at the quick compiler. Startup is faster, steady-state throughput is lower. Fine for a small internal tool, wrong for a busy API.
- **`-Xshare:auto` with AppCDS.** Class data sharing archives make startup faster. Spring Boot 3.3 and newer supports building one from the jar; the Spring Boot documentation has the current steps.
- **Lazy initialisation.** `spring.main.lazy-initialization=true` defers bean creation to first use. Start is faster, but the first request pays for it, and misconfigured beans are found by users instead of at boot. For production, leave it off.
- **Index and migrate before you start.** A slow start is often a Flyway migration or a repository scan, not the JVM.

If cold start dominates your problem, Quarkus and Micronaut are built for it: both do their dependency injection analysis at build time and start in a second or two on the JVM. A native image brings it under 100 ms, at the price of long builds that need several gigabytes of memory, so do those in CI.

#### Troubleshooting

**`UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime`.** The jar was compiled for a newer Java than the server runs. Compile with `--release 17`, or pick Java 21 or 25 for the server.

**`no main manifest attribute, in app.jar`.** You uploaded a thin jar, usually the `.original` file from Maven or the `-plain.jar` from Gradle. Upload the repackaged one.

**`Web server failed to start. Port 8080 was already in use`.** The app is trying to bind to a port that is not yours, or two copies are running. Set `SERVER_PORT` to the allocated port and make sure the old process stopped.

**The server restarts with no stack trace, over and over.** The process exceeded the container memory limit. Lower the heap, for instance `MaxRAMPercentage=55`, and check the memory graph against the limit. Remember that three restarts in an hour trigger a warning.

**`java.lang.OutOfMemoryError: Java heap space`.** The heap is too small, or something is accumulating. Raise the heap if the plan has room, or take a heap dump with `-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof` and open it in a profiler. Delete dumps afterwards; they are as large as the heap.

**`HikariPool-1 - Connection is not available, request timed out after 30000ms`.** The pool is exhausted. Look for slow queries and for `open-in-view` holding connections, before raising `maximum-pool-size`.

**`Connection refused` or `UnknownHostException` for the database.** The host or port in `SPRING_DATASOURCE_URL` is wrong. Copy them from the database page, and remember that MySQL and PostgreSQL ports differ.

**`FATAL: password authentication failed`.** The credentials are wrong or have stray whitespace from a paste. Retype the variable.

**Redirects go to `http://` and sessions never stick.** The forwarded headers are ignored. Set `server.forward-headers-strategy=native`, and check `server.tomcat.remoteip.internal-proxies` if it still fails.

**A bad gateway from the proxy while the console says the app is up.** The app is bound to `127.0.0.1`, listening on the wrong port, or still starting. Wait for the `Started ... in N seconds` line before judging.

**Environment variable seems ignored.** Check the relaxed binding. `SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE` does not work; `SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE` does.

#### FAQ

##### How much memory does a Spring Boot app need?

A small API with JPA and security typically wants a 256 to 384 MB heap and a 600 to 800 MB container, so the 1 GB tier works for a simple service and 2 GB is comfortable. Bigger caches, large result sets and many threads push it up. Measure with the memory graph rather than guessing.

##### Should I use -Xmx or MaxRAMPercentage?

Use `MaxRAMPercentage` when you expect to change plan, since it follows the limit. Use `-Xmx` when you have a measured number you want to keep fixed. If both are set, `-Xmx` takes priority.

##### Can I run Quarkus or Micronaut the same way?

Yes. Both produce a jar that runs with `java -jar`, the heap flags are identical, and the differences are property names: `quarkus.http.port` and `micronaut.server.port` instead of `server.port`. Their faster startup is an advantage on small plans.

##### Which Java version should I pick?

The lowest version your dependencies support is the safest to build for, and Java 21 is a good default to run on. Boot 3 needs at least 17. A jar built for 17 runs on 21 and 25, but not the reverse.

##### Do I need a reverse proxy in front of it?

You need something to terminate TLS and renew certificates, and the proxy slot does that. Set `server.forward-headers-strategy` so your application sees the real client address and the right scheme.

##### Why is my app slow for the first minute?

The JVM runs bytecode in an interpreter first and compiles hot paths as it learns them, so the first requests are slower and CPU use is high during warm-up. A small CPU share lengthens it. If readiness checks fail during that period, give them a longer initial delay.

Java plans start at $4 a month and run Spring Boot, Quarkus, Micronaut or Ktor jars on Java 17, 21 or 25. Every tier has a port allocated, database and backup slots, and a proxy slot with a certificate issued and renewed for you, with deploys pulled from GitHub on start or on push.

[See Java plans](https://www.renode.space/app-hosting#java)

---

### ASP.NET Core authentication: cookies, JWT and Identity

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-authentication-basics

For a website where the browser and the server are the same app, use cookie authentication. For an API called by mobile apps, other servers or a single-page app on a different domain, use bearer tokens - usually JWTs issued by an identity provider. ASP.NET Core Identity is the user store and password logic, not a third authentication scheme; it sits underneath either one. Whichever you pick, the thing that breaks most hosted apps is not the choice at all: it is data protection keys that live inside the container and vanish on redeploy, signing every user out and breaking every form. This post covers the choice, the setup of each, Identity's defaults, and how to persist those keys properly.

#### Authentication, authorisation and the middleware order

Two words, two jobs. **Authentication** works out who the caller is, from a cookie, a token or a certificate, and builds a `ClaimsPrincipal`. **Authorisation** decides whether that principal may do the thing it is asking to do. ASP.NET Core keeps them separate, with a handler per scheme for the first and policies for the second.

```csharp
builder.Services.AddAuthentication(/* default scheme */);
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseForwardedHeaders();   // behind a proxy: before anything that reads scheme or IP
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
```

`WebApplication` adds the authentication and authorisation middleware for you when their services are registered, but writing them out keeps the order visible, and the order matters: authentication must run before authorisation, and both after routing so endpoint metadata such as `[Authorize]` is known. Forwarded headers come first when the app is behind a reverse proxy, otherwise the app believes every request arrived over plain HTTP and cookies marked secure, redirect URLs and OAuth callbacks all go wrong. [ASP.NET Core behind a reverse proxy](https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy) has the exact configuration.

#### Cookies or JWT: choosing by client

| Client | Use | Why |
| --- | --- | --- |
| Server-rendered site (Razor Pages, MVC, Blazor Server) | Cookies | The browser stores and sends them; nothing to manage in JavaScript |
| SPA served from the same domain as the API | Cookies | Same reasons, plus `HttpOnly` keeps the credential away from scripts |
| SPA on a different domain | Tokens, or a backend-for-frontend with cookies | Cross-site cookies are increasingly blocked by browsers |
| Mobile or desktop app | Tokens | No cookie jar to rely on |
| Server-to-server | Tokens (client credentials) | No user, no browser |

The argument that JWTs are "stateless and therefore better" is mostly about scale you do not have. A signed cookie is just as stateless - ASP.NET Core's auth cookie contains the encrypted claims, not a session ID - and it has two properties tokens in JavaScript do not: the browser handles storage and expiry, and an `HttpOnly` cookie cannot be read by an injected script. Tokens earn their place when the client is not a browser, or when a separate identity provider issues them for several APIs.

#### Cookie authentication

```csharp
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/account/login";
        options.AccessDeniedPath = "/account/denied";
        options.ExpireTimeSpan = TimeSpan.FromDays(7);
        options.SlidingExpiration = true;
        options.Cookie.Name = "app_auth";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });
```

The defaults are reasonable: `ExpireTimeSpan` is 14 days, `SlidingExpiration` is on (the cookie is reissued when more than half its lifetime has passed), the cookie is `HttpOnly`, and `SameSite` is `Lax`. Signing in is building a principal and handing it to the handler:

```csharp
var claims = new List<Claim>
{
    new(ClaimTypes.NameIdentifier, user.Id.ToString()),
    new(ClaimTypes.Name, user.Email),
    new(ClaimTypes.Role, user.Role)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
    new ClaimsPrincipal(identity),
    new AuthenticationProperties { IsPersistent = rememberMe });
```

`IsPersistent = false` makes a session cookie that disappears when the browser closes; `true` gives it the expiry above.

Two things catch people. For an API endpoint, the cookie handler's default response to an unauthenticated request is a 302 redirect to the login page, which a JavaScript client receives as an HTML page with status 200. Override `options.Events.OnRedirectToLogin` to return 401 for API paths. And because the browser sends cookies automatically, cookie authentication needs antiforgery protection for state-changing requests: MVC and Razor Pages validate tokens on forms, and from .NET 8 minimal API endpoints that bind form data are protected once you add `app.UseAntiforgery()`.

#### JWT bearer authentication

```bash
$ dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

With an external identity provider - Auth0, Microsoft Entra ID, Keycloak, Duende IdentityServer and similar - you point the handler at the issuer and it downloads the signing keys from the provider's metadata:

```csharp
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://login.example.com/";
        options.Audience = "orders-api";
        options.MapInboundClaims = false;
    });
```

`MapInboundClaims = false` keeps claim names as they are in the token (`sub`, `email`, `role`) instead of translating them to the long `ClaimTypes` URIs, which is what most people expect when they read `User.FindFirst("sub")`. If you turn mapping off, tell the handler which claims carry the name and roles through `TokenValidationParameters.NameClaimType` and `RoleClaimType`.

If your own app issues tokens, you validate them with a key you hold:

```csharp
options.TokenValidationParameters = new TokenValidationParameters
{
    ValidIssuer = "https://api.example.com",
    ValidAudience = "orders-api",
    IssuerSigningKey = new SymmetricSecurityKey(
        Convert.FromBase64String(builder.Configuration["Jwt:SigningKey"]!)),
    ClockSkew = TimeSpan.FromSeconds(30)
};
```

`ClockSkew` defaults to five minutes, which means an expired token is still accepted for five minutes after its `exp`. Lower it once you trust the clocks. The signing key is a secret like a database password - environment variable or secret store, never `appsettings.json` in the repository. [ASP.NET Core configuration and secrets](https://www.renode.space/blog/aspnet-core-configuration-and-secrets) covers where it should live.

Issuing your own JWTs means owning refresh tokens, revocation, key rotation and token storage on the client. Each is solvable, and together they are why "use cookies, or use an identity provider" is the advice for most small projects. If you need tokens and do not want to run a provider, the next section has a middle path.

#### ASP.NET Core Identity

Identity is the user database and the logic around it: password hashing, lockout, email confirmation, two-factor codes, external logins and roles. It stores users through Entity Framework Core, so it works with SQL Server, PostgreSQL, MySQL or anything else EF Core supports.

```csharp
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services
    .AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();

var app = builder.Build();
app.MapGroup("/account").MapIdentityApi<IdentityUser>();
```

`AddIdentityApiEndpoints` and `MapIdentityApi`, added in .NET 8, give a JSON API with `/register`, `/login`, `/refresh`, email confirmation, password reset and two-factor management. The login endpoint returns either a cookie (with `?useCookies=true`) or a bearer token. Those tokens are not JWTs: they are opaque, protected by the data protection system described below, and only the app that issued them can read them. That is a feature for a single app and its own front end; it is the wrong tool when several services must validate the same token.

For server-rendered apps, `AddDefaultIdentity<IdentityUser>()` with the scaffolded Razor Pages UI is the classic route.

Identity's defaults are worth knowing before you ship:

| Option | Default | Comment |
| --- | --- | --- |
| `Password.RequiredLength` | 6 | Raise it; length beats character classes |
| `Password.RequireDigit`, `RequireUppercase`, `RequireLowercase`, `RequireNonAlphanumeric` | `true` | Many teams relax these and raise the length |
| `Lockout.MaxFailedAccessAttempts` | 5 | Only applies when sign-in is called with `lockoutOnFailure: true` |
| `Lockout.DefaultLockoutTimeSpan` | 5 minutes | |
| `SignIn.RequireConfirmedEmail` | `false` | Turn on once you can send email |
| `User.RequireUniqueEmail` | `false` | Usually you want `true` |

The lockout row is the one to read twice. `SignInManager.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure: false)` - which older templates used - does not count failures at all, so a login form built on it can be guessed at forever. Pass `true`, and put rate limiting in front of the login endpoint as well.

Passwords are hashed with PBKDF2 by Identity's `PasswordHasher`, with a format marker so stronger settings in newer versions rehash old passwords on the next successful login.

#### Data protection keys: why redeploys sign everyone out

Every cookie the auth handler writes, every antiforgery token, every Identity bearer token and every password reset link is encrypted with keys from ASP.NET Core's data protection system. By default on Linux, those keys are generated on first use and written to `~/.aspnet/DataProtection-Keys` - inside the container, under the home directory of whichever user runs the app. If that directory does not survive a rebuild or redeploy, the keys do not either. A new deploy generates new keys, and everything encrypted with the old ones becomes unreadable at once.

The symptoms are distinctive: every user signed out after a deploy, `The antiforgery token could not be decrypted` on forms opened before the deploy, and warnings like this at startup:

```text
warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60]
      Storing keys in a directory '/root/.aspnet/DataProtection-Keys' that may not be
      persisted outside of the container. Protected data will be unavailable when
      container is destroyed.
warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]
      No XML encryptor configured. Key may be persisted to storage in unencrypted form.
```

The fix is to put the keys somewhere that outlives the process. The database the app already uses is the simplest place:

```bash
$ dotnet add package Microsoft.AspNetCore.DataProtection.EntityFrameworkCore
```

```csharp
public class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<IdentityUser>(options), IDataProtectionKeyContext
{
    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
}

builder.Services.AddDataProtection()
    .SetApplicationName("orders-web")
    .PersistKeysToDbContext<AppDbContext>();
```

Add a migration for the new table and deploy. Alternatives are `PersistKeysToStackExchangeRedis` (the `Microsoft.AspNetCore.DataProtection.StackExchangeRedis` package, which works against Valkey, as long as that Valkey server persists to disk) or `PersistKeysToFileSystem` with a directory you know survives deploys. `SetApplicationName` matters when several instances or apps must share keys: by default the application name is derived from the content root path, so two copies of the same app deployed to different paths cannot read each other's cookies.

Keys rotate every 90 days by default, and old keys are kept so existing cookies still decrypt. For keys at rest, `ProtectKeysWithCertificate` encrypts them with an X.509 certificate; without it, anyone who can read the key table can forge cookies, so the database holding them deserves the same care as the user table itself. [Database security checklist](https://www.renode.space/blog/database-security-checklist) covers that side.

#### External logins, OpenID Connect and signing out

Letting users sign in with an account they already have - Google, Microsoft, GitHub, or your company's single sign-on - moves password storage, reset flows and two-factor prompts to somebody whose full-time job it is. In ASP.NET Core this is a remote authentication scheme layered on top of the cookie handler: the remote scheme handles the round trip to the provider, and the cookie scheme remembers the result.

```csharp
builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect(options =>
    {
        options.Authority = "https://login.example.com/";
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = "code";
        options.SaveTokens = false;
        options.MapInboundClaims = false;
    });
```

The provider-specific packages - `Microsoft.AspNetCore.Authentication.Google`, `.MicrosoftAccount` and others - are thin wrappers with the endpoints filled in. Each scheme has a callback path that the provider redirects back to (`/signin-oidc` for OpenID Connect, `/signin-google` for Google), and that exact URL, with `https://` and your real domain, must be registered with the provider. Behind a TLS-terminating proxy, the app builds that URL from the request it sees; without forwarded headers it builds an `http://` address, the provider rejects it as unregistered, and the error page you see is the provider's, not yours.

`SaveTokens = false` keeps the provider's access and refresh tokens out of the auth cookie. Store them only if the app calls the provider's APIs on the user's behalf, because they make the cookie large - large enough, with several claims, that it is split into chunked cookies.

Signing out is `HttpContext.SignOutAsync()` for the cookie scheme, plus a sign-out at the provider if you want the user logged out there too. Because the auth cookie is self-contained, deleting a user or changing their role does not affect a cookie already issued: it remains valid until it expires. Identity handles this with a security stamp, a value that changes when the password or security settings change and is re-checked every 30 minutes by default (`SecurityStampValidatorOptions.ValidationInterval`). Shorten the interval if revocation needs to bite faster, or check a "disabled" flag in your own cookie validation event for immediate effect. Two-factor authentication through an authenticator app is built into Identity - `UserManager` generates and verifies TOTP codes and recovery codes - and is worth switching on for every account with administrative rights.

#### Authorisation policies

Authentication tells you who; policies decide what. Roles work and are fine for coarse rules:

```csharp
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Admin", p => p.RequireRole("admin"))
    .AddPolicy("CanRefund", p => p.RequireClaim("permission", "refunds"))
    .SetFallbackPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build());

app.MapPost("/orders/{id}/refund", Refund).RequireAuthorization("CanRefund");
app.MapGet("/health", () => "ok").AllowAnonymous();
```

The fallback policy is the most useful line here: it makes every endpoint require a signed-in user unless it is explicitly marked `[AllowAnonymous]` or `.AllowAnonymous()`. That reverses the default from "forgot the attribute means public" to "forgot the attribute means locked", which is the direction you want mistakes to fail in. Remember to mark the login page, static assets you serve through endpoints, and health checks as anonymous.

Policies that depend on the resource - "users may edit only their own orders" - are resource-based authorisation, done with `IAuthorizationService.AuthorizeAsync(User, order, "EditOrder")` and a handler that receives the order. Do not encode that kind of rule in roles.

#### Troubleshooting

**Signed in, then immediately anonymous again.** The middleware order is wrong, the cookie is marked secure but the app thinks the request is HTTP (forwarded headers missing), or `SameSite=Strict` is dropping the cookie on a cross-site redirect back from a login provider.

**`IDX10500: Signature validation failed` or `IDX10214: Audience validation failed`.** The token was issued for a different audience, or the app is validating with the wrong key or authority. Decode the token locally and compare `iss` and `aud` with your settings.

**Everyone logged out after each deploy.** Data protection keys are not persisted. See above.

**`User.Identity.Name` is null with JWTs.** Claim mapping is off and `NameClaimType` is not set to the claim your tokens use.

**OAuth callback fails with a correlation error.** The correlation cookie did not survive the round trip - commonly because the app generated an `http://` redirect URI behind a TLS-terminating proxy. Fix forwarded headers first.

#### FAQ

##### Is it safe to store a JWT in localStorage?

It works, and any script that runs on your page can read it - so one cross-site scripting bug leaks every token. An `HttpOnly` cookie cannot be read by scripts. For a browser app on the same domain as its API, cookies are the safer default.

##### Do I need ASP.NET Core Identity?

Only if your app stores its own users and passwords. If users sign in with an external provider, or through a single sign-on system you already run, the cookie or JWT handler alone is enough and Identity adds tables you will not use.

##### How long should an auth cookie last?

Long enough that people are not annoyed, short enough that a stolen laptop is not a permanent session. Seven to fourteen days with sliding expiration suits most sites; admin areas deserve shorter lifetimes and two-factor authentication.

##### Why do I get antiforgery errors only after deploying?

Because the token was encrypted with a data protection key that no longer exists. Persist the keys to the database or another store that survives deploys and the errors stop.

##### Can two instances of my app share sign-ins?

Yes, if they share data protection keys and the same application name. Store the keys in a shared database or Valkey and call `SetApplicationName` with the same value on both.

C# / .NET plans include database slots for your user and key tables, a proxy slot that issues the certificate for your domain and passes the client address in `X-Forwarded-For`, and backup slots on every tier. Secrets go in as environment variables, not into the repository.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### ASP.NET Core behind a reverse proxy: forwarded headers

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy

Behind a reverse proxy, ASP.NET Core sees every request as plain HTTP from one address - the proxy's - until you tell it otherwise. The fix is one middleware, `UseForwardedHeaders`, configured to read `X-Forwarded-For` and `X-Forwarded-Proto`, registered before everything else, and told exactly which proxy it may believe. Get that right and the client IP, the scheme, HTTPS redirection, HSTS, secure cookies and OAuth callbacks all fix themselves. Get it wrong in the permissive direction and anyone can choose their own IP address by sending a header.

This post goes through the middleware option by option, then the things that sit next to it: redirect loops, HSTS, WebSockets and SignalR through a proxy, and the timeouts and limits on both sides.

#### What goes wrong behind a proxy

When a proxy terminates TLS and forwards the request to Kestrel over plain HTTP, the connection Kestrel sees is from the proxy, and the original details survive only as headers the proxy added.

```diagram
title: What Kestrel sees with and without forwarded headers
client = Browser | 198.51.100.7, HTTPS
proxy = Reverse proxy | adds X-Forwarded-*
kestrel = Kestrel | plain HTTP
mw = UseForwardedHeaders | rewrites the context
app = Your endpoints | real IP and scheme
client -> proxy : TLS on 443
proxy -> kestrel : HTTP, allocated port
kestrel -> mw
mw -> app
```

Without the middleware, these are all quietly wrong:

- `HttpContext.Connection.RemoteIpAddress` is the proxy's address. Logs show one visitor; a rate limiter partitioned by IP puts every user in one bucket.
- `HttpContext.Request.Scheme` is `http` and `Request.IsHttps` is false.
- `UseHttpsRedirection` redirects requests that already arrived over HTTPS, and the browser loops until it reports too many redirects.
- `UseHsts` never sends its header, because it only adds it to HTTPS responses.
- Cookies with `CookieSecurePolicy.SameAsRequest` are issued without the `Secure` flag.
- Generated absolute URLs, including the `redirect_uri` sent to Google, Microsoft or GitHub during an external login, start with `http://`, and the provider rejects them as a mismatch.

Every one of those is a symptom of the same missing configuration. [What a reverse proxy actually does](https://www.renode.space/blog/what-a-reverse-proxy-does) covers the proxy side in general; this post is about what ASP.NET Core has to do about it.

#### The forwarded headers middleware

The middleware is part of ASP.NET Core; no package is needed.

Program.cs:

```csharp
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); // your proxy, see below
});

var app = builder.Build();

app.UseForwardedHeaders();      // first, before anything that reads IP or scheme
app.UseHsts();
app.UseHttpsRedirection();
// authentication, rate limiting, endpoints...
```

What it does for each request: if the connection comes from a trusted proxy, it reads the forwarded headers, replaces `RemoteIpAddress` and `Scheme` with the values in them, and moves the originals to `X-Original-For` and `X-Original-Proto` so you can still see them. If the connection does not come from a trusted proxy, it does nothing at all.

Order matters. Any middleware registered before `UseForwardedHeaders` sees the proxy's view of the request. Microsoft's guidance is to run it before everything else except diagnostics and error-handling middleware. Request logging in particular has to come after it, or your logs keep recording the proxy.

The options that matter:

| Option | Default | What it controls |
| --- | --- | --- |
| `ForwardedHeaders` | `None` | Which headers to process. Nothing happens until you set it |
| `ForwardLimit` | `1` | How many entries to take from the right of `X-Forwarded-For` |
| `KnownProxies` | loopback | Individual proxy addresses to trust |
| `KnownNetworks` | `127.0.0.0/8` | Address ranges to trust |
| `AllowedHosts` | empty | Hosts accepted from `X-Forwarded-Host` |
| `RequireHeaderSymmetry` | `false` | Require the same number of values in each header |

`ForwardedHeaders.XForwardedHost` is deliberately missing from the example. Turning it on lets the proxy - or anyone who reaches the app directly - change `Request.Host`, which feeds into generated links and password-reset emails. Only enable it with `AllowedHosts` set to your real host names, and only if the proxy rewrites the `Host` header rather than passing it through. Most proxies pass the original `Host` through unchanged, in which case you do not need it.

#### KnownProxies: deciding who to believe

`X-Forwarded-For` is a header, and a client can send any header it likes. The proxy appends the address it actually saw to whatever the client sent, so for a request from `198.51.100.7` that included a forged value, the app receives:

```text
X-Forwarded-For: 6.6.6.6, 198.51.100.7
```

The rightmost entry was written by your proxy and is true. Everything to its left was supplied by the client and is not. With `ForwardLimit = 1` the middleware takes exactly one entry from the right - the true one - which is why the default limit is correct for a single proxy. If there are two proxies in a chain (a CDN in front of your proxy, say), the limit is 2 and both must be trusted.

The other half of the safety is `KnownProxies`. The middleware only processes headers on connections from an address it trusts, and by default that is loopback only. A proxy at any other address is ignored, so a freshly configured app often seems to do nothing: the headers arrive, the middleware sees an unknown source, and skips them silently.

To find out what address the proxy connects from, log it before the middleware runs:

```csharp
app.Use(async (context, next) =>
{
    app.Logger.LogInformation("peer {Peer} xff {Xff}",
        context.Connection.RemoteIpAddress,
        context.Request.Headers["X-Forwarded-For"].ToString());
    await next();
});
app.UseForwardedHeaders();
```

Make one request through the domain, read the address from the log, add it to `KnownProxies` (or the range to `KnownNetworks`), and remove the logging. Check after a restart that the address is the same; if the proxy's source address can change, trust its network rather than one address.

In .NET 10, `KnownNetworks` is marked obsolete in favour of `KnownIPNetworks`, which takes the newer `System.Net.IPNetwork` type. The behaviour is the same; the old property still works and only produces a warning.

> [!WARNING]
> If your app's port is reachable directly from the internet as well as through the proxy - which it is when the port allocation is on a public address - then trusting every source means anyone can connect straight to the port with a forged `X-Forwarded-For` and become whichever IP they like. Trust the proxy's address, not the world.

#### The environment variable shortcut, and its cost

ASP.NET Core can turn the middleware on without code:

```env
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
```

This adds the middleware with `XForwardedFor` and `XForwardedProto` enabled - and it also clears `KnownProxies` and `KnownNetworks`, so it accepts the headers from any source. It was designed for platforms where the app is only reachable through the proxy, so there is nobody else to send them.

On a server where the app port is also reachable directly, that is the forged-IP problem from the warning above. It is fine for a quick test, and acceptable when the client IP is used for nothing more than logs. For anything that makes a decision on the client IP - rate limits, IP allow-lists, fraud scoring, login throttling - configure `KnownProxies` in code instead. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) explains why a forgeable key is worse than no rate limit at all.

Here is what depends on it in a typical app. The built-in rate limiter partitions by whatever key you give it, and the usual key is the client address:

```csharp
builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            }));
});
```

With the middleware missing, that key is the proxy for every request, and the hundredth request in a minute from anyone blocks everyone. With the middleware trusting all sources, the key is whatever the attacker writes, and they get a fresh hundred requests by changing one header. Only the correctly scoped version gives you a limit per real client. `UseRateLimiter` must also come after `UseForwardedHeaders` in the pipeline, for the same reason logging must.

#### HTTPS redirection without a redirect loop

`UseHttpsRedirection` answers HTTP requests with a 307 to the HTTPS URL. Behind a TLS-terminating proxy it has two failure modes.

**The loop.** Without `X-Forwarded-Proto` processed, every request looks like HTTP, so every request is redirected, including the ones that arrived over HTTPS. The forwarded headers configuration above fixes this.

**The silent no-op.** The middleware has to know which port to redirect to. It looks at `HttpsRedirectionOptions.HttpsPort`, then the `https_port` setting (`ASPNETCORE_HTTPS_PORT` in the environment), then the HTTPS endpoints Kestrel is listening on. Behind a proxy, Kestrel has no HTTPS endpoint, so the middleware logs "Failed to determine the https port for redirect" and does nothing. If you want the app to do the redirect, set the port the browser should use:

```env
ASPNETCORE_HTTPS_PORT=443
```

Before you do, check whether the proxy already redirects plain HTTP to HTTPS for your domain. If it does, the app's redirect never fires and you can leave the middleware out. Two layers redirecting is harmless; two layers that disagree about the scheme is how the loop starts. On RE:NODE, the app plan's proxy slot holds the certificate for your domain - point an A record at the address shown and it is issued and renewed automatically inside a 21-day window - and the client address arrives in `X-Forwarded-For`. [Your domain and its certificate](https://www.renode.space/blog/your-domain-and-its-certificate) covers the DNS side.

#### HSTS

HSTS tells the browser to use HTTPS for your host for a period, without asking. In ASP.NET Core:

```csharp
builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(180);
    options.IncludeSubDomains = false;
    options.Preload = false;
});

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}
```

The default `MaxAge` is 30 days. The middleware only adds the header to HTTPS responses, and it never adds it for `localhost`, `127.0.0.1` or `[::1]`, which is why testing locally shows nothing.

Be careful with two settings. `IncludeSubDomains` applies the policy to every subdomain, including the one running an old admin tool on plain HTTP that you forgot about. `Preload` asks to be included in the list browsers ship with, which is very hard to undo. Start with a short `MaxAge`, confirm that every part of the site works over HTTPS, then lengthen it. If the proxy already sends `Strict-Transport-Security`, do not send it twice with different values.

#### WebSockets and SignalR through the proxy

A WebSocket starts as an HTTP request with `Upgrade: websocket` and `Connection: Upgrade` headers, and the proxy has to pass both through and then hold the connection open. Most current proxies do this for HTTP/1.1. On the app side:

```csharp
app.UseWebSockets(new WebSocketOptions
{
    KeepAliveInterval = TimeSpan.FromSeconds(30)
});
```

The default `KeepAliveInterval` is two minutes. A proxy that closes idle connections after 60 seconds will cut a quiet WebSocket long before the app sends its first ping, and the client sees an unexplained disconnect every minute. Keep the interval shorter than the shortest idle timeout between the browser and Kestrel.

SignalR handles this for you to a degree: the server sends a keep-alive every 15 seconds by default (`KeepAliveInterval`), and considers a client gone after 30 seconds without hearing from it (`ClientTimeoutInterval`). If WebSockets fail through the proxy, SignalR falls back to Server-Sent Events or long polling, which work but cost more requests and more latency. Check the browser's network tab: a connection that negotiates and then switches to long polling is usually a proxy that did not pass the upgrade. [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) has the proxy-side detail, and [SignalR real-time apps](https://www.renode.space/blog/signalr-real-time-apps-hosting) covers the hub configuration.

#### Timeouts, body sizes and keep-alive

The proxy and Kestrel each have limits, and a request has to fit inside both.

| Kestrel setting | Default | Notes |
| --- | --- | --- |
| `Limits.KeepAliveTimeout` | 130 seconds | Idle time before Kestrel closes a connection |
| `Limits.RequestHeadersTimeout` | 30 seconds | Time allowed to receive headers |
| `Limits.MaxRequestBodySize` | 30,000,000 bytes | About 28.6 MB; override per endpoint |
| `Limits.MaxConcurrentConnections` | unlimited | Rarely worth setting behind a proxy |

Kestrel's two-minute keep-alive is longer than the idle timeout of most proxies, which is the safe way round: the proxy closes idle connections first, so it never sends a request down a connection Kestrel is about to drop. That race is the classic cause of occasional 502s with nothing in the application log, and it is less common with Kestrel than with Node precisely because of this default. If you lowered `KeepAliveTimeout`, put it back.

For uploads, the smaller of the two body limits wins. Raise Kestrel's limit for the one endpoint that needs it with `[RequestSizeLimit]` on a controller action, or through `IHttpMaxRequestBodySizeFeature` at the start of the request, rather than globally; a large global limit is an easy way to let one client fill your memory. A request that is slower than the proxy's read timeout - a long report, a big export - should become a background job that the client polls, not a two-minute HTTP request. [.NET worker services and background jobs](https://www.renode.space/blog/dotnet-worker-services-and-background-jobs) shows the pattern.

#### Checking that it works

Add a temporary diagnostic endpoint, call it once through the domain, and remove it:

```csharp
app.MapGet("/debug/request", (HttpContext ctx) => new
{
    ip = ctx.Connection.RemoteIpAddress?.ToString(),
    scheme = ctx.Request.Scheme,
    host = ctx.Request.Host.Value,
    originalFor = ctx.Request.Headers["X-Original-For"].ToString()
});
```

Through the domain, `ip` should be your own public address, `scheme` should be `https`, and `originalFor` should show the proxy. Then call the app directly on its IP and port with a forged header:

```bash
$ curl -H "X-Forwarded-For: 1.2.3.4" http://203.0.113.10:25571/debug/request
```

The response must not say `1.2.3.4`. If it does, you are trusting sources you should not. On RE:NODE the app's port is allocated on the server's address, so this direct test is a real path an attacker could use, not a hypothetical one.

The last check is a response header: `Strict-Transport-Security` should appear on HTTPS responses through the domain if you enabled HSTS. If none of this works and the log shows nothing, go back to the logging middleware above and look at the peer address; nine times out of ten it is not in `KnownProxies`.

#### FAQ

##### Why does UseForwardedHeaders seem to do nothing?

Either `ForwardedHeaders` is still `None`, or the proxy's address is not in `KnownProxies` or `KnownNetworks`. The middleware ignores headers from untrusted sources without logging anything at the default level, so log the peer address and compare.

##### Should I set ForwardLimit to null to read the whole header?

No. Taking more entries than you have proxies means trusting values the client wrote. Set the limit to the number of proxies you run, which for a single proxy slot is the default of 1.

##### Do I still need UseHttpsRedirection if the proxy redirects?

Not if the proxy already redirects every HTTP request for your domain. It is harmless to keep, provided `X-Forwarded-Proto` is processed so the app agrees with the proxy about which requests are already HTTPS.

##### Why do external logins fail with a redirect URI mismatch?

The app built the callback URL from `Request.Scheme`, which was `http` because the forwarded proto header was not processed. Fix the forwarded headers and the generated URL becomes `https://`, matching what you registered with the provider.

##### Does the proxy change how I configure Kestrel's ports?

Kestrel listens on plain HTTP on the allocated port, on all interfaces, with no certificate. [Deploy an ASP.NET Core app from GitHub](https://www.renode.space/blog/deploy-aspnet-core-app-from-github) has the `ASPNETCORE_URLS` setup.

Every C# / .NET plan on RE:NODE includes a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, with the real client address passed in `X-Forwarded-For`. Plans start at 1 GB with half a vCPU from $4 a month and deploy straight from a GitHub repository.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### ASP.NET Core configuration and secrets, from laptop to server

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-configuration-and-secrets

ASP.NET Core reads configuration from a stack of sources, and the last one to set a key wins: `appsettings.json`, then `appsettings.{Environment}.json`, then user secrets (in Development only), then environment variables, then command-line arguments. On your laptop, secrets come from user secrets. On a server, they come from environment variables, written with a double underscore where the JSON had nesting - `ConnectionStrings__Default`, `Smtp__Password`. Bind each section to a typed class with the options pattern, validate it when the app starts, and a missing password becomes a clear error in the first second rather than a null reference on the first request.

That is the whole model. The rest of this post is the detail that decides whether it works: how keys are named, which interface rereads changes, what user secrets actually are, and the mistakes that leave a production app running on development settings.

#### Where configuration comes from

`WebApplication.CreateBuilder(args)` sets up the default sources for you. In order, from lowest priority to highest:

| Order | Source | Loaded when |
| --- | --- | --- |
| 1 | `appsettings.json` | Always, if present |
| 2 | `appsettings.{Environment}.json` | Always, if present |
| 3 | User secrets | Environment is `Development` |
| 4 | Environment variables | Always |
| 5 | Command-line arguments | Always |

Every source contributes a flat set of keys. When two sources set the same key, the later one wins; keys that only one source sets survive untouched. That is what makes the layering useful: `appsettings.json` holds every setting with a safe default, the environment file changes a handful, and the environment variables on the server supply the few that are secret or specific to that machine.

It also means a stale value can hide. An environment variable set on the server months ago overrides whatever you change in `appsettings.Production.json` today, and nothing tells you. When a setting refuses to change, look at the higher layers first.

#### Keys, sections and the double underscore

Configuration is a tree flattened into keys joined by colons. This JSON:

appsettings.json:

```json
{
  "ConnectionStrings": {
    "Default": "Host=localhost;Database=shop;Username=shop;Password=dev"
  },
  "Smtp": {
    "Host": "smtp.example.com",
    "Port": 587,
    "From": "shop@example.com"
  },
  "Cors": {
    "Origins": [ "https://example.com", "https://www.example.com" ]
  },
  "Logging": {
    "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
  }
}
```

produces keys such as `Smtp:Host`, `Cors:Origins:0` and `Logging:LogLevel:Default`. Arrays become numbered children. Keys are case-insensitive, so `smtp:host` reads the same value.

Environment variable names cannot portably contain a colon, so the environment provider accepts a double underscore instead and translates it:

```env
ConnectionStrings__Default=Host=203.0.113.10;Port=5432;Database=shop;Username=shop;Password=...
Smtp__Password=...
Cors__Origins__0=https://example.com
Cors__Origins__1=https://www.example.com
Logging__LogLevel__Default=Warning
```

Two details here catch people. An array set from environment variables replaces elements by index, not the whole array, so if the JSON has three origins and the environment sets `__0` and `__1`, the third from JSON is still there. And a single underscore is just a character: `Smtp_Password` is a different key from `Smtp:Password` and is silently ignored by everything that reads the latter.

Reading values directly works for one-offs:

```csharp
var connection = builder.Configuration.GetConnectionString("Default");
var smtpHost = builder.Configuration["Smtp:Host"];
var port = builder.Configuration.GetValue<int>("Smtp:Port", 587);
```

`GetConnectionString("Default")` is shorthand for `Configuration["ConnectionStrings:Default"]`. For anything with more than one setting, use the options pattern below instead of scattering string keys through the code.

#### Environments and per-environment files

The environment name comes from `ASPNETCORE_ENVIRONMENT`, or `DOTNET_ENVIRONMENT` if that is not set, and defaults to `Production` when neither is. The templates set `Development` in `launchSettings.json`, which is used by `dotnet run` and Visual Studio but not by a published app - which is exactly why a server runs as Production without you doing anything.

The environment decides three things:

- which `appsettings.{Environment}.json` is loaded;
- whether user secrets are loaded (only in Development, by default);
- what `app.Environment.IsDevelopment()` returns, which the templates use to switch the developer exception page on and HSTS off.

Use `Staging` for a second deployment that should behave like production but point at test data. `IsStaging()` exists and `appsettings.Staging.json` is loaded automatically. [Staging and production on one account](https://www.renode.space/blog/staging-and-production-on-one-account) covers running the two side by side.

What belongs in each file: `appsettings.json` gets every key the app reads, with values that are safe to commit - hosts, ports, feature flags, timeouts. `appsettings.Development.json` gets local overrides such as verbose logging. `appsettings.Production.json` is often unnecessary; if it exists, it holds non-secret production values. None of them hold passwords, API keys or connection strings with credentials in them, because all of them are committed.

#### User secrets in development

User secrets keep development credentials out of the repository by storing them in your user profile instead of the project folder.

```bash
$ dotnet user-secrets init
$ dotnet user-secrets set "ConnectionStrings:Default" "Host=localhost;Password=dev-only"
$ dotnet user-secrets set "Smtp:Password" "app-password-for-testing"
$ dotnet user-secrets list
```

`init` adds a `UserSecretsId` GUID to the project file. The values go into a plain JSON file at `%APPDATA%\Microsoft\UserSecrets\<id>\secrets.json` on Windows, or `~/.microsoft/usersecrets/<id>/secrets.json` on Linux and macOS.

Understand what that is and is not. The file is not encrypted; it is ordinary JSON in your home directory. Its only job is to keep secrets out of the project tree, so they cannot be committed by accident. It is loaded only when the environment is Development, so it is never a deployment mechanism. A team shares nothing through it - each developer sets their own.

That last point produces the most common first-deploy failure in .NET: everything works locally because the connection string lives in user secrets, and on the server the key simply does not exist. The app starts, takes a request, and throws because the connection string is null. Validation at startup, below, turns that into an immediate and readable failure.

#### Secrets on the server

On a server, environment variables are the standard place for secrets. They are set outside the repository, read through the normal configuration system with no code change, and they override every file.

On RE:NODE you set them on the Startup tab of the server, and they apply on the next start of the container - so change a value, then restart. An app plan includes two database slots, created in the panel with a generated host, user and password; copy those into `ConnectionStrings__Default` rather than putting them in a committed file. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the general rules and what to do on the day a secret ends up in Git history anyway.

Things not to do:

- **Do not commit a production `appsettings.Production.json` with credentials and add it to `.gitignore` later.** It is in the history. Rotate the credential.
- **Do not log your configuration.** `IConfigurationRoot.GetDebugView()` is a useful method that prints every key, every value and which provider set it - including passwords. Use it locally; never write its output to a production log.
- **Do not pass secrets as command-line arguments.** They show up in process listings and are easy to paste into a support ticket.

If you would rather read secrets from files than from the environment - one file per key, the way container secret mounts work - the `Microsoft.Extensions.Configuration.KeyPerFile` package adds that as a source with `builder.Configuration.AddKeyPerFile("/path/to/secrets", optional: true)`. A file named `Smtp__Password` in that directory becomes the key `Smtp:Password`. External vaults (Azure Key Vault, HashiCorp Vault, AWS Secrets Manager) have configuration providers too; they make sense when you already run one, not as a first step for a single app.

##### One setting, followed through every layer

It helps to trace a single section from laptop to server. Take the `Smtp` section above.

On your laptop the environment is Development. `Smtp:Host`, `Smtp:Port` and `Smtp:From` come from `appsettings.json`. `appsettings.Development.json` changes `Smtp:Host` to `localhost` and `Smtp:Port` to `1025`, pointing at a local mail catcher. `Smtp:Password` comes from user secrets. Four keys, three sources, and nothing secret in the repository.

On the server the environment is Production. `appsettings.Development.json` and user secrets are not loaded at all. `Smtp:Host`, `Smtp:Port` and `Smtp:From` come from `appsettings.json` again, so the committed defaults should be the production ones. `Smtp:Password` comes from the `Smtp__Password` environment variable on the Startup tab. If you later need to point production at a different relay for an afternoon, setting `Smtp__Host` in the environment overrides the file without a commit, and removing the variable puts the committed value back.

The habit that falls out of this: commit production-safe defaults to the base file, put development conveniences in the Development file, and keep the environment for secrets and for deliberate, temporary overrides. If you find yourself setting a dozen non-secret variables on the server, those values probably belong in a committed file instead, where they are reviewed and versioned like the rest of the code.

#### The options pattern

Rather than reading strings, bind a section to a class:

```csharp
public sealed class SmtpOptions
{
    public const string Section = "Smtp";

    [Required] public string Host { get; set; } = "";
    [Range(1, 65535)] public int Port { get; set; } = 587;
    [Required, EmailAddress] public string From { get; set; } = "";
    [Required] public string Password { get; set; } = "";
}
```

Program.cs:

```csharp
builder.Services.AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.Section)
    .ValidateDataAnnotations()
    .ValidateOnStart();
```

Then inject it where it is needed. There are three interfaces, and the difference is when the values are read:

| Interface | Lifetime | Sees changes after start |
| --- | --- | --- |
| `IOptions<T>` | Singleton | No - read once, on first use |
| `IOptionsSnapshot<T>` | Scoped | Yes - recomputed per request |
| `IOptionsMonitor<T>` | Singleton | Yes - `CurrentValue`, plus `OnChange` |

Use `IOptions<T>` by default. It is the cheapest and the most predictable: what the app read at startup is what it uses until it restarts. `IOptionsSnapshot<T>` cannot be injected into a singleton, because it is scoped; that mistake produces a dependency injection error at startup. `IOptionsMonitor<T>` is for singletons that genuinely need to react to changes, such as a background service whose polling interval you want to tune live.

The binder sets public properties with setters, matching names case-insensitively. A property with no matching key keeps its default, silently. That is why validation matters: without it, a typo in a key name produces an object full of defaults and no error at all.

#### Validating at startup

`ValidateDataAnnotations()` checks the attributes on the class. `ValidateOnStart()` makes the check run when the host starts, instead of the first time something asks for the options. Together they turn a missing secret into this, in the first second of the log:

```text
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:
DataAnnotation validation failed for 'SmtpOptions' members: 'Password' with the error:
'The Password field is required.'.
```

That is the behaviour you want on a server. A process that refuses to start with a sentence naming the missing key is a two-minute fix. One that starts, accepts traffic and fails on the first email is an incident.

For rules that attributes cannot express, add a delegate:

```csharp
builder.Services.AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.Section)
    .Validate(o => o.Port != 25 || o.Host.EndsWith(".internal"),
        "Port 25 is only allowed for the internal relay.")
    .ValidateOnStart();
```

Data annotations validate the top-level properties of the options class; nested objects are not validated recursively unless you validate them explicitly. If your options have nested sections, give each its own options class and its own validation.

A process that exits at startup will be restarted by most platforms, and repeatedly. On RE:NODE, three unexpected restarts within an hour raise a warning on the server page and open a ticket automatically, so a configuration error does not loop unnoticed. Read the first lines of the log rather than the last.

#### Reloading without a redeploy

The JSON sources are registered with `reloadOnChange: true`, so editing `appsettings.json` on the server's disk updates the configuration in a running app. Whether your code notices depends on the interface: `IOptionsMonitor<T>` and `IOptionsSnapshot<T>` see the change, `IOptions<T>` does not, and values you copied into fields at startup obviously do not.

Logging is the one place this is genuinely useful. Log levels are read through the monitor, so changing `Logging:LogLevel:Default` in the file on disk raises or lowers verbosity without a restart - handy while chasing a production problem. For everything else, treat configuration as fixed for the life of the process, change it through the environment, and restart. Environment variables are read once at process start and never reload. Files edited on the server are also overwritten by the next deploy if they are tracked in the repository, which is a good reason not to hand-edit tracked files on a server at all. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) has more on choosing levels.

#### Common mistakes

**The setting will not change.** A higher layer sets it. Check environment variables first, then command-line arguments.

**It works locally and is null on the server.** The value lives in user secrets, which only load in Development. Set it as an environment variable on the server.

**The server is running with development settings.** `ASPNETCORE_ENVIRONMENT=Development` was copied into the server's environment from a tutorial. Remove it; the default is Production.

**An environment variable is ignored.** A single underscore instead of two, a typo in the section name, or the app was not restarted after the change.

**Options are all defaults.** The section name passed to `BindConfiguration` does not match the JSON, or the properties have no setters. Validation would have caught it.

**"Cannot consume scoped service IOptionsSnapshot from singleton".** Use `IOptionsMonitor<T>` in singletons.

**A number or boolean is wrong.** Every configuration value is a string until it is bound. `"Port": "587"` and `"Port": 587` bind the same, but a boolean written as `yes`, or a port written as `587/tcp`, fails conversion - and the error names the key, so read it.

**The secret is in the log anyway.** Something logged the whole options object, or an exception message included the connection string. Override `ToString()` on options classes that hold secrets, and check what your logging library does with objects passed as structured properties.

#### FAQ

##### Is it safe to keep secrets in environment variables?

Safe enough for most apps, and far safer than committed files. Anyone who can read the process environment or the panel's Startup tab can read them, so limit who has that access - [subusers and least privilege](https://www.renode.space/blog/subusers-and-least-privilege) covers splitting panel permissions.

##### Should appsettings.Production.json be committed?

Yes, if it holds only non-secret values. If it needs a secret, that secret belongs in an environment variable instead, and the file should not contain it at all.

##### Does ASPNETCORE_ENVIRONMENT need to be set on a server?

No. The default is Production, which is correct. Set it only for a deliberately different environment such as Staging.

##### How do I set a connection string with a semicolon in an environment variable?

Set it as it is; the semicolons are part of the value and need no escaping on the Startup tab. Only a shell would need quotes around it. [.NET with PostgreSQL, MySQL or SQL Server](https://www.renode.space/blog/dotnet-with-postgresql-mysql-or-sql-server) shows a working connection string for each provider.

##### Can I change configuration without restarting the app?

For values read through `IOptionsMonitor<T>` or log levels, editing the JSON file on disk works. Environment variables always need a restart. For a predictable app, prefer restarting.

C# / .NET plans on RE:NODE take secrets as environment variables on the Startup tab, include two database slots with generated credentials, and deploy from a GitHub repository with pull-on-start and deploy-on-push. Plans start at 1 GB with half a vCPU from $4 a month.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### ASP.NET Core logging and health checks in production

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-logging-and-health-checks

An ASP.NET Core app in production needs two things from you before it needs anything clever: logs that say what happened, at a level you chose on purpose, and an endpoint that says whether the app can do its job right now. The built-in `ILogger` covers the first if you set `Logging:LogLevel` deliberately and write structured messages instead of interpolated strings. `AddHealthChecks()` plus `MapHealthChecks("/healthz")` covers the second in two lines. Serilog is worth adding when you want files, enrichment or a log server; it is not required to log well. This post goes through both halves in the order you will meet them, with the defaults, the configuration keys, and the mistakes that turn logging into a disk-full incident.

#### How logging works in ASP.NET Core

Logging is part of the generic host. When you call `WebApplication.CreateBuilder(args)`, the builder registers the console, debug and event source providers (plus the Windows event log on Windows) and reads the `Logging` section of configuration. Anything that asks for an `ILogger<T>` through dependency injection receives a logger whose category is the full name of `T`, and every log call is filtered by that category before it reaches a provider.

```csharp
public class OrderService(ILogger<OrderService> logger, AppDbContext db)
{
    public async Task PlaceAsync(Order order)
    {
        db.Orders.Add(order);
        await db.SaveChangesAsync();
        logger.LogInformation("Order {OrderId} placed for {CustomerId}",
            order.Id, order.CustomerId);
    }
}
```

The category matters because it is what the filter matches against. `Microsoft.EntityFrameworkCore.Database.Command` is the category that logs every SQL statement, `Microsoft.AspNetCore.Hosting.Diagnostics` logs request start and finish, and `MyApp.OrderService` is the one above. Filters match by prefix, so a rule for `Microsoft.AspNetCore` covers everything beneath it.

There are seven levels, and they are an ordered scale rather than a set of labels:

| Level | Value | Use it for |
| --- | --- | --- |
| `Trace` | 0 | Step-by-step detail, possibly containing sensitive data. Never on in production |
| `Debug` | 1 | Developer diagnostics. Off in production unless you are hunting something |
| `Information` | 2 | Normal events worth a line: started, order placed, job finished |
| `Warning` | 3 | Something odd that the app recovered from |
| `Error` | 4 | An operation failed; the app keeps running |
| `Critical` | 5 | The app or a core dependency is down |
| `None` | 6 | Used only in filters, to switch a category off |

A filter set to `Warning` passes `Warning`, `Error` and `Critical` and drops the rest. The cost of a dropped message is very small - the logger checks the level before formatting anything - which is why you can leave `LogDebug` calls in code without paying for them.

#### Setting log levels in appsettings and environment variables

The template's `appsettings.json` is close to right for production:

appsettings.json:

```json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  }
}
```

`Default` applies to every category without a more specific rule. `Microsoft.AspNetCore` at `Warning` silences the framework's per-request chatter, which otherwise produces two or more lines for every request, including every static file. The Entity Framework line is the one people forget: at `Information`, EF Core logs each SQL command it executes with its duration, which is useful on your laptop and a flood under load.

Configuration is layered, so you rarely need to edit the file to change a level on a running deployment. Environment variables override JSON, and the separator for nested keys is a double underscore:

```env
Logging__LogLevel__Default=Warning
Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command=Information
```

A category name with dots works as an environment variable key on Linux. `appsettings.Production.json` is loaded on top of `appsettings.json` when `ASPNETCORE_ENVIRONMENT` (or `DOTNET_ENVIRONMENT`) is `Production`, which is also the default when neither is set. [ASP.NET Core configuration and secrets](https://www.renode.space/blog/aspnet-core-configuration-and-secrets) explains the whole layering order, and it is worth reading before you put a connection string anywhere.

Changes to `appsettings.json` on disk are picked up while the app runs, because the default JSON source has `reloadOnChange` set. Environment variables are read once at start, so changing one means a restart.

#### Writing log messages that are worth reading

The single most common logging mistake in .NET is string interpolation:

```csharp
// Wrong: formatted every time, and the values are lost as fields
logger.LogInformation($"Order {order.Id} placed for {order.CustomerId}");

// Right: a message template with named placeholders
logger.LogInformation("Order {OrderId} placed for {CustomerId}",
    order.Id, order.CustomerId);
```

The second form is a message template. The placeholders are matched to the arguments by position, not by name, and the names become properties on the log event. With the plain console logger you see the same sentence either way. With a structured provider - the JSON console formatter, Serilog, or anything that ships to a log server - you get `OrderId` and `CustomerId` as fields you can filter on, and the template itself stays constant, so "how many times did this message happen" becomes a simple count. The interpolated version is also formatted even when the level is filtered out, because the string is built before the method is called.

For hot paths, the source-generated `LoggerMessage` attribute removes the remaining cost: no boxing of value types, no parsing of the template at runtime, and a check of the level before anything is evaluated.

```csharp
public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Warning,
        Message = "Payment provider slow: {ElapsedMs} ms for order {OrderId}")]
    public static partial void PaymentSlow(ILogger logger, long elapsedMs, int orderId);
}
```

Three habits are worth more than any provider choice:

- **Log exceptions as exceptions.** `logger.LogError(ex, "Charging order {OrderId} failed", id)` keeps the stack trace as a structured field. Putting `ex.Message` into the template throws away the part you need.
- **One line per event, not per step.** A request that logs ten `Information` lines on the happy path will bury the one `Warning` that matters.
- **Never log secrets or whole request bodies.** Passwords, tokens, connection strings and card numbers end up in log files that are copied, downloaded and kept far longer than the data they describe.

Scopes attach context to every message written inside them, which is how you get an order number onto lines logged by code that does not know about orders. `using (logger.BeginScope("Order {OrderId}", id)) { ... }` does it; the console provider shows scopes only if `IncludeScopes` is enabled on its formatter, while structured providers record them as properties.

#### Console output in a container

On a container host, the console is the log. Whatever the process writes to standard output is what the panel shows you, and what is kept between restarts depends on the host, so treat the console as a live view rather than an archive. The default simple formatter writes two lines per message (the category on one, the text on the next), which reads badly in a live console. Two changes help:

```csharp
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
    options.SingleLine = true;
    options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
    options.UseUtcTimestamp = true;
});
```

If something downstream parses your logs, use `builder.Logging.AddJsonConsole()` instead, which writes one JSON object per line with the template, the rendered message, and every named property.

On RE:NODE the console tab shows the process's output unfiltered and live, alongside graphs for memory, CPU and disk against the plan's limits, so a single-line, timestamped console is what you will actually be reading when something goes wrong. It is not a log archive: if you need last Tuesday's errors next month, write them somewhere that keeps them.

#### Serilog: when it is worth adding

Serilog earns its place when you want one of three things the built-in providers do not do well: rolling log files with retention, enrichment (machine name, request ID, user ID on every event), or shipping to a log server such as Seq, Elasticsearch or Grafana Loki through a sink. The setup is a package and a few lines:

```bash
$ dotnet add package Serilog.AspNetCore
$ dotnet add package Serilog.Sinks.File
```

```csharp
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddSerilog((services, lc) => lc
        .ReadFrom.Configuration(builder.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.UseSerilogRequestLogging();
    // ... endpoints
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
    Log.CloseAndFlush();
}
```

The bootstrap logger catches failures during startup, before configuration is loaded - which is exactly when a missing environment variable crashes the app and the default setup prints very little. `Log.CloseAndFlush()` matters for asynchronous and batching sinks: without it, the last messages before a crash are still in a buffer when the process exits.

`UseSerilogRequestLogging()` replaces the framework's several lines per request with one line carrying method, path, status code and elapsed time, which is the request log most people actually want. Combine it with a `Microsoft.AspNetCore` override at `Warning` so the framework's own lines are not written as well.

The configuration lives in its own section, which replaces `Logging` once Serilog is in charge:

appsettings.json:

```json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "logs/app-.log",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 14,
          "fileSizeLimitBytes": 52428800,
          "rollOnFileSizeLimit": true
        }
      }
    ]
  }
}
```

> [!WARNING]
> Log files are the most common reason a small app server runs out of disk. The file sink keeps 31 files by default and caps each at 1 GB, which on a plan with 5 or 10 GB of storage is not a cap at all. Set `retainedFileCountLimit` and `fileSizeLimitBytes` explicitly, and size them against the disk you actually have.

If you do not need files, do not write them. A console-only app that ships to a log server through a sink has nothing to fill the disk with. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) goes through what to retain and for how long.

#### Health checks: liveness, readiness and dependencies

A health check endpoint answers one question for a machine: can this instance serve requests right now? ASP.NET Core ships the plumbing in the box.

```csharp
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddDbContextCheck<AppDbContext>(tags: ["ready"]);

var app = builder.Build();

app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});
```

`AddDbContextCheck` comes from the `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` package and simply asks EF Core whether it can connect. For checks against a database without EF Core, the community `AspNetCore.HealthChecks.*` packages provide `AddSqlServer`, `AddNpgSql`, `AddMySql`, `AddRedis` and many others; they are widely used but not part of the framework, so pin their versions like any other dependency.

The response is plain text - `Healthy`, `Degraded` or `Unhealthy` - with status codes mapped like this by default:

| Result | HTTP status | Meaning |
| --- | --- | --- |
| `Healthy` | 200 | Everything checked is fine |
| `Degraded` | 200 | Working, but something is slow or partly failing |
| `Unhealthy` | 503 | Do not send traffic here |

The split between liveness and readiness is the part that prevents outages instead of causing them. **Liveness** asks "is the process alive and not wedged" and should check nothing external. **Readiness** asks "can it serve real requests", and is where the database belongs. If your only endpoint checks the database and something restarts the app whenever that endpoint fails, a five-second database hiccup restarts every instance at once, and they all reconnect together. A liveness check that touches no dependency cannot cause that.

A custom check is a class implementing `IHealthCheck`:

```csharp
public class QueueDepthCheck(IJobQueue queue) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        var depth = await queue.CountAsync(cancellationToken);
        return depth switch
        {
            < 1_000 => HealthCheckResult.Healthy($"{depth} jobs queued"),
            < 10_000 => HealthCheckResult.Degraded($"{depth} jobs queued"),
            _ => HealthCheckResult.Unhealthy($"{depth} jobs queued")
        };
    }
}

builder.Services.AddHealthChecks()
    .AddCheck<QueueDepthCheck>("queue", timeout: TimeSpan.FromSeconds(3), tags: ["ready"]);
```

Give every check that touches the network a timeout. A health endpoint that hangs for thirty seconds is worse than one that returns 503, because whatever polls it may hold connections open and pile up.

#### Securing and exposing the health endpoint

The default response is a single word, which is safe to expose. The moment you add a detailed writer - the popular `UIResponseWriter.WriteHealthCheckUIResponse` from `AspNetCore.HealthChecks.UI.Client`, or your own JSON with each check's description and exception - you are publishing the names of your dependencies and sometimes their error messages. Keep the public endpoint terse and put the detailed one behind authorisation or a host restriction:

```csharp
app.MapHealthChecks("/healthz/ready");                 // public, one word
app.MapHealthChecks("/healthz/detail", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
}).RequireAuthorization("Ops");
```

Exclude health endpoints from request logging, or a monitor polling every thirty seconds writes nearly three thousand lines a day of nothing. With Serilog, the `GetLevel` option of `UseSerilogRequestLogging` can drop them to `Verbose`.

Know what will actually call the endpoint. On RE:NODE the platform's crash watcher polls every two minutes for a server whose uptime went backwards or that went offline, and opens a ticket after three unexpected restarts in an hour. It watches the process; it does not call your `/healthz`. A wedged app whose process is still alive and returning 503 will not be restarted by it, so point an external uptime monitor at the readiness URL through your domain. [Monitoring that tells you something](https://www.renode.space/blog/monitoring-that-tells-you-something) covers what to alert on, and [graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers the other half - flipping readiness to unhealthy when the app is stopping, so a proxy stops sending it traffic before it exits.

#### Request logging, HTTP logging and correlation

Three request-level tools exist, and they are not interchangeable:

- **Serilog request logging** - one summary line per request. The right default.
- **`AddHttpLogging` / `UseHttpLogging`** - the framework's HTTP logging middleware, which can include headers and bodies. It logs at `Information` under the `Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware` category, so it shows nothing until that category is allowed through the filter. Useful for a short debugging session; dangerous left on, because headers include cookies and authorisation tokens unless you restrict `RequestHeaders` and `ResponseHeaders`.
- **`AddW3CLogging`** - an access log in the W3C extended format, written to files. Handy if you already have tools that read IIS-style logs.

For correlation, every request already has `HttpContext.TraceIdentifier`, and with the default `Activity` tracking each log event inside a request carries a `TraceId` and `SpanId` when the provider records them (the JSON console formatter and Serilog both can). Return the trace ID in error responses - `ProblemDetails` includes a `traceId` extension by default when you use `AddProblemDetails()` - and a user's screenshot of an error page becomes a direct search in your logs.

Behind a reverse proxy, the client address in your request logs is the proxy's until you enable forwarded headers. The real client IP arrives in `X-Forwarded-For`, and [ASP.NET Core behind a reverse proxy](https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy) shows the `ForwardedHeadersOptions` that make `HttpContext.Connection.RemoteIpAddress` correct, which is what both your logs and any rate limiter read.

#### Troubleshooting

**Nothing is logged at all.** Usually `ClearProviders()` was called and nothing was added back, or Serilog was configured but its `WriteTo` section is empty or misspelt. Serilog's `SelfLog.Enable(Console.Error)` prints its own configuration errors.

**The console is flooded with SQL.** `Microsoft.EntityFrameworkCore.Database.Command` is at `Information`, either explicitly or through `Default`. Add the override at `Warning`.

**Startup crashes with no useful output.** The exception happened before logging was configured. Use a bootstrap logger (Serilog) or wrap `builder.Build()` and `app.Run()` in a try/catch that writes to `Console.Error`, then read the console from the top.

**Health endpoint returns 503 but the app works.** One check tagged into that endpoint is failing or timing out. Temporarily map a detailed writer behind authorisation and read which one; it is usually a dependency the endpoint should not have been checking.

**The disk filled up.** Find the log directory first. Rolling file sinks with default retention, `Debug` left on, and HTTP logging with bodies are the three usual causes, in that order.

**Log lines show the proxy's address for every client.** Forwarded headers are not configured, or `KnownProxies` does not include the proxy's address, so the middleware ignores the header.

#### FAQ

##### Do I need Serilog, or is the built-in logging enough?

The built-in `ILogger` with the JSON console formatter is enough for an app whose logs are read from the console or collected from standard output. Serilog is worth adding for rolling files with retention, enrichment on every event, or a sink to a log server. Your code keeps calling `ILogger` either way, so switching later costs one setup change.

##### What log level should production run at?

`Information` for your own categories, `Warning` for `Microsoft.AspNetCore` and EF Core. Raise one category to `Debug` temporarily through an environment variable when you are chasing a problem, then put it back.

##### Should the health check test the database?

The readiness check should, because an app that cannot reach its database cannot serve most requests. The liveness check should not, because restarting the app does not fix the database and restarting every instance at once makes recovery slower.

##### Why are my structured properties missing from the output?

Either the message was built with string interpolation, so there are no placeholders to capture, or the provider is the plain simple console formatter, which renders the sentence and discards the fields. Use a message template and a structured formatter or sink.

##### How often should a monitor call the health endpoint?

Every 30 to 60 seconds is enough for a small app. More often adds load and log noise without telling you much sooner, and alerting after two consecutive failures avoids paging yourself over a single slow response.

C# / .NET plans start at $4 a month and deploy from GitHub, with a live console and resource graphs, database and backup slots, and a proxy slot that issues the certificate for your domain and passes the client address in `X-Forwarded-For`. Every plan gets the same panel.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### ASP.NET Core performance on small servers

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-performance-on-small-servers

ASP.NET Core is one of the fastest web stacks there is, and on a small server it is still easy to make slow. The framework is rarely the problem. On half a core and a gigabyte of memory, what costs you is blocking on async code, allocating megabytes per request, sending uncompressed JSON, recomputing the same response a thousand times a minute, and letting an unbounded queue of requests pile up instead of refusing the excess. Fix those five and a single small instance handles more traffic than most projects ever see. This post goes through each one with the settings, their defaults, and how to tell which one is actually hurting you before you change anything.

#### What a small server changes

A .NET app sized on a developer laptop meets two hard limits in a container: a CPU quota and a memory limit. Both change how the runtime behaves.

The runtime reads the CPU quota from the container's cgroup and sets `Environment.ProcessorCount` from it, rounded up. On a plan with half a vCPU, the app believes it has one processor, which affects how many garbage collector heaps it creates, how many thread pool threads it starts with, and how much parallelism libraries choose by default. The quota is still a hard throttle: once the process has used its share of a scheduling period, it waits for the next one, which shows up as latency spikes rather than as an error.

Memory is the other ceiling. The garbage collector reads the container's limit and sizes its heap budget against it, so the app will not grow without bound, but every allocation still costs CPU time later in collections. On RE:NODE, a server that reaches its memory limit is stopped by the kernel and restarted clean rather than left to swap, so a memory spike is an outage, not a slowdown. [.NET memory and garbage collection](https://www.renode.space/blog/dotnet-memory-and-garbage-collection) covers the collector settings in depth; this post covers what the application does to create the pressure.

| Plan size | What it comfortably runs | Where it runs out first |
| --- | --- | --- |
| 0.5 vCPU, 1 GB | An API or small site, tens of requests per second | CPU during bursts and GC |
| 1 vCPU, 2 GB | A busy API, Blazor Server for a few dozen users | Memory per connection, CPU in serialisation |
| 1.5-2 vCPU, 4 GB | A product with real traffic and background jobs | Database round trips, not the app |
| 3 vCPU, 8 GB | Several apps or heavy in-process caching | Usually you have a design problem by now |

Those rows are rough. An app that renders server-side pages with large models, or holds big caches in memory, moves up a row; a thin JSON API over a well-indexed database moves down one.

#### Measure before you tune

Every tuning step below has a cost, and most performance work on small apps is wasted on the wrong layer. Get three numbers first:

1. **Where does the time go in one slow request?** Log elapsed time per request (Serilog's request logging does this in one line) and, for the slow ones, how long the database part took. EF Core logs each command's duration at `Information` under `Microsoft.EntityFrameworkCore.Database.Command`; turn it on briefly.
2. **Is the CPU at its limit under load?** The panel's CPU graph against the plan's limit answers this. Flat at the ceiling means CPU-bound; low CPU with high latency means you are waiting on something - the database, an external API, or the thread pool.
3. **How does it behave under concurrency?** Run a load test from another machine with `k6`, `bombardier` or `wrk` at a realistic request mix, and watch latency percentiles, not the average.

On your own machine, `dotnet-counters` gives the runtime's view while a load test runs:

```bash
$ dotnet tool install --global dotnet-counters
$ dotnet-counters monitor --name MyApp System.Runtime Microsoft.AspNetCore.Hosting
```

The counters that matter are the thread pool queue length, the thread pool thread count, the GC heap size and the time spent in GC, and requests per second. A growing thread pool queue with low CPU is the signature of the most common .NET performance bug, which comes next. The exact counter names changed between .NET 8 and .NET 9, when the runtime moved to a new `System.Runtime` meter, so read them from the tool's output rather than from an old blog post.

#### Thread pool starvation and sync-over-async

ASP.NET Core serves requests on thread pool threads. An `async` method that awaits I/O hands its thread back while it waits, so a handful of threads can serve thousands of concurrent requests. Code that blocks on async work - `.Result`, `.Wait()`, `.GetAwaiter().GetResult()`, or a synchronous database or HTTP call - holds a thread for the whole wait.

```csharp
// Blocks a thread pool thread for the full round trip
var user = db.Users.FirstOrDefaultAsync(u => u.Id == id).Result;

// Hands the thread back while waiting
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == id);
```

When enough threads are blocked, new requests queue. The pool does add threads, but deliberately slowly - its injection heuristic is designed not to overreact, and adding a few threads per second is nowhere near enough when a burst arrives. The result is the classic starvation pattern: CPU low, latency climbing into seconds, then timeouts, then recovery once the burst passes. On a one-core plan the pool starts with very few threads, so it takes surprisingly little blocking to trigger.

The fix is to make the path async all the way down. The usual culprits:

- Synchronous EF Core calls (`ToList()`, `SaveChanges()`, `First()`) in request handlers. Use the `Async` versions.
- `HttpClient` calls wrapped in `.Result` because the caller was not async. Make the caller async.
- Synchronous reads of the request body. Kestrel rejects these by default (`AllowSynchronousIO` is `false`) with an `InvalidOperationException`, and turning that setting on to silence the error is how you reintroduce starvation.
- Locks around async work. Use `SemaphoreSlim.WaitAsync()` rather than `lock`, which cannot contain an `await` anyway.

`ThreadPool.SetMinThreads` raises the starting number of threads and is a legitimate stopgap while you remove the blocking calls, but it is a stopgap: more blocked threads still means more memory and more context switching on a single core.

#### Kestrel limits that protect a small server

Kestrel's defaults are generous because they are written for servers of every size. A small server benefits from saying no early instead of accepting work it cannot finish.

| Setting | Default | Why you might change it |
| --- | --- | --- |
| `MaxConcurrentConnections` | unlimited | Cap open connections so a flood queues at the proxy, not in your memory |
| `MaxConcurrentUpgradedConnections` | unlimited | Same for WebSockets and SignalR |
| `MaxRequestBodySize` | 30,000,000 bytes | Lower it for an API that never accepts uploads |
| `KeepAliveTimeout` | 130 seconds | Shorter frees idle connections sooner |
| `RequestHeadersTimeout` | 30 seconds | Shorter limits slow-header attacks |
| `MinRequestBodyDataRate` | 240 bytes/s after 5 s grace | Drops clients trickling a body to hold a connection |
| `MaxRequestHeadersTotalSize` | 32 KB | Rarely needs changing |

```csharp
builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxConcurrentConnections = 500;
    options.Limits.MaxConcurrentUpgradedConnections = 200;
    options.Limits.MaxRequestBodySize = 2 * 1024 * 1024;
    options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
});
```

The same values can live in configuration under `Kestrel:Limits`, which lets you change them with an environment variable such as `Kestrel__Limits__MaxConcurrentConnections=500` instead of a rebuild. An individual endpoint that does accept uploads can raise the body limit for itself with `[RequestSizeLimit]` or the `IHttpMaxRequestBodySizeFeature`.

Connection limits do not limit work in progress, though. For that, the rate limiting middleware (in the box since .NET 7) has a concurrency limiter, which is the most useful single protection for a small instance: it lets a fixed number of requests run at once, queues a few more, and rejects the rest with 503 instead of letting latency grow without bound.

```csharp
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
        RateLimitPartition.GetConcurrencyLimiter("global", _ =>
            new ConcurrencyLimiterOptions { PermitLimit = 64, QueueLimit = 32 }));
});

app.UseRateLimiter();
```

Per-client limits (fixed window, sliding window, token bucket) live in the same middleware and are the right tool against one noisy client, as long as forwarded headers are configured so the partition key is the real client address. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) has the reasoning for picking numbers.

#### Response compression

JSON and HTML compress to a fraction of their size, and on a small server bandwidth is rarely the constraint - latency for the client is. Compression trades a little CPU for much less time on the wire.

```csharp
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(o =>
    o.Level = CompressionLevel.Fastest);

app.UseResponseCompression();
```

Three cautions. `EnableForHttps` is off by default because compressing responses that mix secrets with attacker-controlled input over TLS is the setting for the CRIME and BREACH family of attacks; it is safe for public JSON and pages without secrets in them, and you should think before enabling it on responses that carry tokens. `CompressionLevel.Fastest` is the right level for dynamic responses on a small CPU; the optimal level can cost several times the CPU for a few percent less output. And if a reverse proxy in front of the app already compresses, do not compress twice: it wastes CPU and the proxy will usually pass your compressed body through unchanged anyway. Check the response headers - `Content-Encoding: br` or `gzip` - with compression disabled in the app, and you will know whether the proxy is doing it.

Static assets are better compressed once at build time than on every request. `MapStaticAssets()` in .NET 9 and later does this for files known at build time, serving precompressed versions with fingerprinted names and long cache lifetimes.

#### Output caching and response caching

The cheapest request is the one you do not compute. ASP.NET Core has two caching middlewares and they are often confused:

- **Response caching** (`AddResponseCaching`) follows HTTP caching headers. It caches only what the headers allow, and a browser sending `Cache-Control: no-cache` bypasses it. It is mostly useful for setting headers correctly for downstream caches.
- **Output caching** (`AddOutputCache`, .NET 7 and later) is a server-side cache you control. The client cannot bypass it, entries can be tagged and evicted, and concurrent requests for the same uncached entry are collapsed into one computation, which protects a small server from a stampede.

```csharp
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(10)));
    options.AddPolicy("Products", b => b.Expire(TimeSpan.FromMinutes(5)).Tag("products"));
});

app.UseOutputCache();

app.MapGet("/products", GetProducts).CacheOutput("Products");

app.MapPost("/products", async (Product p, IOutputCacheStore cache, CancellationToken ct) =>
{
    // ... save
    await cache.EvictByTagAsync("products", ct);
});
```

By default, output caching stores only `GET` and `HEAD` responses with status 200, and skips requests that are authenticated and responses that set cookies, which is what keeps one user's page from being served to another. The default store is in memory with a 100 MB size limit; on a 1 GB plan, set `SizeLimit` lower. For several instances sharing one cache, .NET 8 added a Redis-protocol store (`Microsoft.AspNetCore.OutputCaching.StackExchangeRedis`, registered with `AddStackExchangeRedisOutputCache`), which works against Valkey. [Valkey caching patterns](https://www.renode.space/blog/valkey-caching-patterns) covers what to cache and how to expire it.

Even ten seconds of output caching on a busy endpoint turns hundreds of database queries a minute into six. That is usually the biggest single win available, and it costs a few lines.

#### Allocations, serialisation and the database

On a small server the garbage collector competes with your requests for the same CPU, so allocation rate is throughput. The common sources, roughly in order of how often they matter:

- **Loading more than you need from the database.** `ToListAsync()` on an unfiltered query, or entities with every column when the page shows three. Project with `Select` into a small record, page with `Skip` and `Take`, and use `AsNoTracking()` for reads - change tracking keeps a snapshot of every entity it loads.
- **N+1 queries.** A loop that lazily loads a relation per row turns one request into a hundred round trips. Use `Include` or a projection, and read the SQL EF Core actually generates.
- **Buffering large responses.** Returning a `List<T>` of fifty thousand rows builds the whole list and then the whole JSON in memory. Page it, or return an `IAsyncEnumerable<T>`, which `System.Text.Json` streams.
- **New `HttpClient` per request.** Use `IHttpClientFactory` or a single long-lived client. Creating clients per request exhausts sockets and allocates for nothing.
- **Reflection-based serialisation on hot paths.** `System.Text.Json` source generation (`JsonSerializerContext`) removes the reflection and some allocation, and is required for trimmed or Native AOT builds anyway.
- **String building in loops and large `byte[]` buffers.** `StringBuilder`, `ArrayPool<T>.Shared` and `Span<T>` exist for this.

The database is usually the slowest component in a small deployment, and nothing in this post helps a query missing an index. A connection pool sized far beyond what the database can serve also hurts more than it helps; [connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) explains why the right pool is smaller than people expect.

#### Startup, the GC mode and publishing options

A few build and runtime settings matter specifically on small machines:

MyApp.csproj:

```xml
<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
  <TieredPGO>true</TieredPGO>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
```

`InvariantGlobalization` drops the ICU culture data, saving memory and startup time, at the cost of culture-specific formatting and sorting - fine for most APIs, wrong for an app that formats dates and currencies per user. `TieredPGO` is already on by default from .NET 8, so listing it only documents intent. `PublishReadyToRun` precompiles code so the first requests after a restart are not slowed by the JIT, which matters when a deploy restarts the app under traffic; it needs a runtime identifier at publish time and makes the output larger. [dotnet publish and runtime options](https://www.renode.space/blog/dotnet-publish-and-runtime-options) covers RIDs, self-contained builds and trimming.

ASP.NET Core apps use Server GC by default. With one visible processor the runtime uses workstation GC anyway, and from .NET 9 Server GC enables DATAS, which adapts the heap count to the load and keeps memory much lower than older Server GC did. If an app on a 2-core plan sits at a higher baseline memory than you can afford, `<ServerGarbageCollector>false</ServerGarbageCollector>` trades some throughput for a smaller footprint; measure both before deciding.

#### FAQ

##### How many requests per second can a small ASP.NET Core server handle?

For simple JSON endpoints that hit a cache or a fast query, thousands on one core. For endpoints that query a database each time, the database sets the number, typically hundreds. The framework is rarely the limit; your slowest dependency and your allocation rate are.

##### Is Kestrel safe to expose without a reverse proxy?

Kestrel is a supported edge server and handles TLS and HTTP/2 itself. A proxy in front is still useful for certificates, a stable domain and header handling, and it absorbs slow clients before they reach your app. If you expose Kestrel directly, set the limits in this post deliberately.

##### Should I enable response compression in the app or at the proxy?

At one of them, not both. If the proxy in front of you compresses, leave it there. If it does not, enable it in the app with Brotli and gzip at the fastest level, and think about `EnableForHttps` for responses that contain secrets.

##### Why does my app get slow under load while CPU stays low?

Almost always thread pool starvation from blocking on async code, or waiting on a saturated database connection pool. A growing thread pool queue in `dotnet-counters` confirms the first; slow query logs or pool timeout errors confirm the second.

##### Does Native AOT make sense on a small server?

It gives faster startup and a smaller memory footprint, but not every library supports it, and in .NET 8 and later only part of ASP.NET Core is compatible - minimal APIs and gRPC, not MVC or Blazor Server. For a small, self-contained API it can be worth it; for a typical app with EF Core and MVC, ReadyToRun plus the fixes above is the better trade.

C# / .NET plans run from 0.5 vCPU and 1 GB up to 3 vCPU and 8 GB, with the CPU and memory limits shown as graphs in the console, so you can see which one you are actually hitting. Moving up a tier changes the limits on the server you already have.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### Blazor Server vs WebAssembly: what each costs to host

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/blazor-server-vs-webassembly-hosting

Blazor Server runs your components on the server and sends UI updates to the browser over a SignalR connection: every open tab is a live circuit holding state in server memory, every click is a round trip, and the server's memory grows with concurrent users rather than with requests. Blazor WebAssembly downloads the .NET runtime and your app into the browser and runs it there: the server only serves static files and an API, but the first visit costs a multi-megabyte download and nothing in the client can be secret. Since .NET 8 you no longer have to pick one for the whole app - the Blazor Web App model renders pages statically on the server by default and lets each component choose Server, WebAssembly or Auto interactivity.

For hosting, the question is simple: how many people will be connected at once, how far are they from the server, and can the server afford to remember all of them? This post puts numbers on each of those.

#### The render modes in one table

A Blazor Web App (the default template since .NET 8) supports four ways to render a component:

| Render mode | Where code runs | Interactive | Server cost per visitor |
| --- | --- | --- | --- |
| Static server-side (SSR) | Server, per request | No (forms and links only) | A normal HTTP request |
| `InteractiveServer` | Server, in a circuit | Yes | A live circuit for the whole visit |
| `InteractiveWebAssembly` | Browser | Yes | Static files, then API calls |
| `InteractiveAuto` | Server first, then browser | Yes | A circuit until the runtime is cached |

Static SSR is easy to overlook and often the right answer. A page that shows data and posts a form needs no interactivity at all; it renders like a Razor Page and costs the server nothing after the response is sent. Enhanced navigation makes links between static pages feel like a single-page app, and `[StreamRendering]` lets a slow page send its layout first and the data when it arrives.

Interactivity is set per component or for the whole app:

Program.cs:

```csharp
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
```

A component then declares `@rendermode InteractiveServer` (or one of the others), and only that component and its children pay for interactivity. The older standalone Blazor Server and "ASP.NET Core hosted" WebAssembly templates are gone from .NET 8 onwards; existing apps built on them still run.

#### How Blazor Server works

When a page with an interactive server component loads, the browser opens a SignalR connection back to the server, preferably over WebSockets. The server creates a circuit: an instance of every interactive component on the page, their state, the render tree, and a scoped dependency injection container. From then on:

1. The user clicks a button. The browser sends the event over the connection.
2. The server runs the event handler, re-renders the affected components, and computes the difference from the last render.
3. The server sends the diff back, and the browser patches the DOM.

```diagram
title: One click in a Blazor Server app
browser = Browser tab | blazor.web.js
proxy = Reverse proxy | WebSocket upgrade
circuit = Circuit | component state
db = Database | queries
browser <-> proxy : SignalR
proxy <-> circuit
circuit --> db : event handlers
```

This is why Blazor Server feels like writing a desktop app: the code can call the database directly, there is no API layer to build, and nothing sensitive ever reaches the browser. It is also why its costs are unlike a normal web app's. A circuit lives as long as the tab is open, whether the user is doing anything or not, and the scoped services in it - including an EF Core `DbContext` if you inject one directly - live just as long.

#### Memory and CPU per user

Microsoft's own guidance puts the minimum at roughly 250 KB of server memory per connected user for a very simple app. Real apps hold far more: every component's fields, any data you loaded into them, grids with thousands of rows, and the scoped services. A circuit holding a 5,000-row grid with its data is measured in megabytes, and it holds it for the whole visit.

A rough way to size it: measure one circuit. Open the app in one tab, note the memory graph, open twenty more tabs on the heaviest page, and divide the increase by twenty. Then multiply by the number of people you expect to have the app open at the same moment - not per day, but at once.

| Concurrent users | Light pages (about 0.5 MB each) | Heavy pages (about 5 MB each) |
| --- | --- | --- |
| 20 | 10 MB | 100 MB |
| 200 | 100 MB | 1 GB |
| 1,000 | 500 MB | 5 GB |

Those per-user figures are illustrative, not measured on your app; the point is the shape. A Blazor Server app that is fine with an office full of users can be wrong for a public site where thousands might arrive at once. [.NET memory and garbage collection](https://www.renode.space/blog/dotnet-memory-and-garbage-collection) covers the base memory of the process on top of the circuits.

The circuit options that bound this:

| `CircuitOptions` setting | Default | What it does |
| --- | --- | --- |
| `DisconnectedCircuitRetentionPeriod` | 3 minutes | How long a dropped circuit is kept for reconnection |
| `DisconnectedCircuitMaxRetained` | 100 | How many dropped circuits are kept at once |
| `JSInteropDefaultCallTimeout` | 1 minute | Timeout for calls into JavaScript |
| `MaxBufferedUnacknowledgedRenderBatches` | 10 | Render batches queued for a slow client |
| `DetailedErrors` | `false` | Send exception details to the browser - development only |

Keep data out of components when you can: load a page of results, not the table. Use `Virtualize` for long lists so only the visible rows are rendered. Create a `DbContext` per operation with `IDbContextFactory<T>` instead of injecting one for the life of the circuit.

CPU is usually the lesser problem, because most events are cheap. It matters when many users trigger expensive renders at once - a dashboard re-rendering on a timer for every connected user is a load that grows with the audience even when nobody touches anything.

#### Latency and the connection

Every interaction in Blazor Server waits for the network. At 20 ms between the user and the server, a button feels instant. At 150 ms, typing into a field that re-renders on every keystroke feels sticky, and drag-and-drop feels broken. If your users are on another continent from the server, test from there before you commit to Server mode. [Latency, jitter and packet loss](https://www.renode.space/blog/latency-jitter-and-packet-loss) explains what the numbers mean in practice.

The connection also has to survive. Blazor Server needs a persistent connection through every proxy between the browser and Kestrel:

- WebSockets must pass the upgrade. If they cannot, SignalR falls back to long polling, which works but makes every event slower and costs more requests. The browser's network tab shows a `101 Switching Protocols` response when WebSockets are working.
- Idle timeouts on the proxy must be longer than SignalR's keep-alive, which pings every 15 seconds by default.
- The app must see the real client and scheme through forwarded headers - see [ASP.NET Core behind a reverse proxy](https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy).

When the connection drops, the browser shows a reconnection overlay and retries. If it reconnects within the retention period, the circuit resumes where it was. If not, state is gone and the user must reload. Mobile users switching networks and laptops waking from sleep hit this constantly, and .NET 9 and 10 have both improved the reconnection experience; .NET 10 adds a way to persist circuit state so a user can resume after a longer gap. Check what your version supports before relying on it.

#### How Blazor WebAssembly works

WebAssembly mode ships your app to the browser. The first visit downloads the .NET runtime compiled to WebAssembly, the framework assemblies your app uses, and your own assemblies, then runs everything locally. After that, the browser caches the files and later visits start quickly.

The trade-offs mirror Server mode's:

- **The download.** A published app is trimmed (on by default for WebAssembly) and compressed, but expect a first-load payload measured in megabytes rather than kilobytes - a small app lands somewhere around 2 to 4 MB compressed, a larger one more. On a slow mobile connection that is seconds of loading screen.
- **No secrets.** Everything in the client can be read by the user. Connection strings, API keys and business rules that must not be bypassed belong on the server, behind an API.
- **Execution speed.** By default your code is interpreted by the WebAssembly runtime. Ahead-of-time compilation (`<RunAOTCompilation>true</RunAOTCompilation>`, which needs the `wasm-tools` workload to build) makes CPU-heavy code much faster and makes the download considerably larger.
- **Server cost.** Almost none for the UI. The server serves static files once per user and then answers API calls, which scale like any other API.

#### Hosting a WebAssembly app

A standalone Blazor WebAssembly app (the `blazorwasm` template) publishes to a folder of static files under `wwwroot`, and any static host can serve it. Three details decide whether it works:

1. **Fallback routing.** Blazor handles routes in the browser, so a request for `/orders/42` must return `index.html`, not a 404. The static host needs a rewrite rule for unknown paths; how you add one depends on the server. [Static site hosting](https://www.renode.space/blog/static-site-hosting-guide) covers the common cases.
2. **Compression and MIME types.** Publishing writes pre-compressed `.br` and `.gz` copies of each file. Serving those, and serving `.wasm` as `application/wasm`, is the difference between a 3 MB and a 10 MB first load.
3. **Cache headers.** The runtime files are fingerprinted, so they can be cached for a long time; `index.html` must not be, or users keep loading old versions after a deploy. [HTTP caching headers explained](https://www.renode.space/blog/http-caching-headers-explained) has the reasoning.

The API the client calls is a separate ASP.NET Core app. If it is on a different origin from the static files, configure CORS on the API. In a Blazor Web App with `InteractiveWebAssembly` components, the ASP.NET Core server serves both the client files and the API from one origin, which avoids CORS entirely and is the simplest arrangement to host.

#### Prerendering and the double load

Interactive components in a Blazor Web App are prerendered by default: the server renders the HTML once so the page appears immediately and search engines can read it, then the component starts again in interactive mode and renders a second time. Any data loading in `OnInitializedAsync` therefore runs twice - once during prerendering, once when the circuit or the WebAssembly runtime takes over.

Two ways to deal with it:

- **Persist the state across the two renders.** `PersistentComponentState` (since .NET 8) serialises data from the prerender into the page and hands it to the interactive render. .NET 10 adds a declarative `[PersistentState]` attribute on a component property that does the same with less code.
- **Turn prerendering off** for components where the first paint does not matter, with `@rendermode @(new InteractiveServerRenderMode(prerender: false))`. The page then shows nothing for that component until interactivity starts.

Prerendering also means code in `OnInitializedAsync` runs on the server even for a WebAssembly component, so it must not assume it is in a browser - no `IJSRuntime` calls there.

#### Deploys, restarts and lost circuits

A deploy restarts the process, and every Blazor Server circuit dies with it. Users see the reconnection overlay, the reconnection fails because the server they knew has gone, and they have to reload, losing anything not saved. With deploy-on-push turned on, every push to the branch does this to every connected user.

So for a Blazor Server app: save user input often, deploy at quiet times, and do not treat the circuit as storage. WebAssembly apps are much more forgiving - the UI keeps running in the browser while the API restarts, and only calls made during the gap fail. [Zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers what you can and cannot avoid with one instance.

One more thing that must survive a restart: the Data Protection keys used for antiforgery tokens and authentication cookies. If they are regenerated, every user is signed out. [Deploy an ASP.NET Core app from GitHub](https://www.renode.space/blog/deploy-aspnet-core-app-from-github) shows where to persist them.

#### Choosing

- **Mostly content and forms**: static SSR, with interactivity only on the components that need it. Cheapest to host by far.
- **Internal tools and admin panels with tens of users near the server**: `InteractiveServer`. No API to build, fast first load, memory is a non-issue at that scale.
- **Public apps with many concurrent users, or users far from the server**: `InteractiveWebAssembly` with an API. The server scales with requests, not with open tabs.
- **You want fast first load and WebAssembly afterwards**: `InteractiveAuto`, accepting that you now write components that must work in both places.

A worked example makes the split concrete. A booking site for a sports club has a public timetable, a booking form, and a staff screen for managing courts. The timetable is static SSR with stream rendering: it is read far more than anything else, and it costs nothing to keep open. The booking form is static SSR too - a normal form post with validation needs no circuit. The staff screen, used by four people on the club's own network, is `InteractiveServer`: rich, immediate, talking straight to the database. The result runs comfortably on a small plan, because the only circuits that exist belong to the four people who benefit from them. The same site built entirely in Server mode would hold a circuit for every member checking the timetable on a Saturday morning.

#### FAQ

##### How many users can a Blazor Server app handle on 2 GB?

It depends entirely on what each circuit holds. A light app holding half a megabyte per user fits a few hundred concurrent users alongside the process itself; a data-heavy one may manage a few dozen. Measure one circuit and multiply.

##### Does Blazor WebAssembly need a .NET server at all?

Not for the UI. A standalone WebAssembly app is static files and can be served by any static host. You only need a .NET server for the API it calls - which you will almost always have.

##### Why does my Blazor Server app disconnect every minute?

A proxy idle timeout shorter than the keep-alive interval, or WebSockets not passing through so the fallback transport keeps timing out. Check the network tab for the transport in use.

##### Can I scale Blazor Server across several servers?

Yes, with sticky sessions so each user's connection always reaches the server holding their circuit. On a single server that question does not arise; [SignalR real-time apps](https://www.renode.space/blog/signalr-real-time-apps-hosting) covers scale-out for plain SignalR.

##### Is InteractiveAuto the best of both?

It is a good default for public apps, but every Auto component must run both on the server and in the browser, so it cannot touch the database directly. That constraint is the price of the fast first load.

C# / .NET plans on RE:NODE run Blazor Web Apps and APIs from a GitHub repository, from 1 GB with half a vCPU at $4 a month up to 8 GB. Each plan includes a proxy slot with automatic certificates and the client address in `X-Forwarded-For`, two database slots, and a memory graph against the limit so you can measure what a circuit costs.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

## Rules for the assistant

- Never ask the user for a password, token, API key or `.env` contents, and tell them not to paste one into this chat. Describe where a secret goes instead.
- Secrets reach the server through the panel: `.env` is uploaded through the Files tab, startup variables are set on the Startup tab. `.env` never goes in the repository.
- An app server has exactly one reachable port. Read it from `SERVER_PORT` (also `PORT` when the panel starts a linked repository) and bind `0.0.0.0`; never hard-code a port.
- On every panel-managed server (everything except VDS and dedicated servers, which come with full root) only `/home/container` persists, and the process runs unprivileged: no root, `sudo`, `apt`, `systemd` or Docker.
- Memory is a hard limit; a process that reaches it is killed with no error message. Suggest a bigger plan only after ruling out a leak.
- Do not suggest changing the server's startup command or image to work around a code problem - fix the code or the repository.
- Prices and plans above were read when this file was generated. Send the user to the page linked at the top before quoting a price as current.
- For a specific server, the panel's Connect AI link (Console tab) gives the exact runtime, port, path and branch. Prefer it over these general rules when the user has one.
- If something here does not answer the question, the full deploy docs are at https://www.renode.space/llms-full.txt and support is at https://www.renode.space/contact.
