# 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.5 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.

**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-19 18:29 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 |

### Elixir

Run an Elixir service or a Phoenix web app: your Mix project is compiled on every start and runs on your own port, with a secret key generated for you. Deploy from GitHub or upload by SFTP.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 5 GB | 2 | 1 | 1 |
| Standard | 7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Pro | 12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 3 | 1 |

### Gleam

Run a Gleam service or web app on the BEAM or on Node: your project is built on every start and runs on your own port. Wisp and Mist work as they are. Deploy from GitHub or upload by SFTP.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 5 GB | 2 | 1 | 1 |
| Standard | 7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Pro | 12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 3 | 1 |

### Nim

Run a Nim service or web app: your project is compiled to a native binary on every start and runs on your own port. Jester and Prologue work. Deploy from GitHub or upload by SFTP.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 5 GB | 2 | 1 | 1 |
| Standard | 7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Pro | 12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 3 | 1 |

### Dart

Run a Dart server: your project is compiled to a native executable on every start and runs on your own port. Shelf and Dart Frog work. Deploy from GitHub or upload by SFTP.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 5 GB | 2 | 1 | 1 |
| Standard | 7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Pro | 12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 3 | 1 |

### Zig

Run a Zig service or a Ziex web app: your project is built with zig build on every start and runs on your own port. Deploy from GitHub or upload by SFTP.

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

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

### Ruby

Run a Ruby app: Rails, Sinatra or any Rack app, with your gems installed by bundler on every start and running on your own port. Deploy from GitHub or upload by SFTP.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Lite | 5 | 1 GB | 1 vCPU | 5 GB | 2 | 1 | 1 |
| Starter | 7 | 2 GB | 1 vCPU | 10 GB | 2 | 2 | 1 |
| Standard | 12 | 4 GB | 1.5 vCPU | 20 GB | 2 | 3 | 1 |

### Crystal

Run a Crystal service: your project is built with shards build --release on every start and runs on your own port. Kemal, Lucky and plain HTTP::Server apps. Deploy from GitHub or upload by SFTP.

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

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

### PocketBase

PocketBase, an open-source backend in one file: a SQLite database with a REST and realtime API, user authentication, file storage and an admin dashboard. Installed and running with a superuser ready to sign in.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| PocketBase Starter | 4 | 1 GB | 0.5 vCPU | 5 GB | 0 | 1 | 1 |
| PocketBase Standard | 7 | 2 GB | 1 vCPU | 15 GB | 0 | 2 | 1 |
| PocketBase Pro | 12 | 4 GB | 2 vCPU | 40 GB | 0 | 4 | 1 |

### Meilisearch

Meilisearch, the fast, typo-tolerant search engine: add instant search to a website or an app through a REST API. Runs in production mode behind a per-server master key.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Meilisearch Starter | 5 | 1 GB | 1 vCPU | 10 GB | 0 | 1 | 1 |
| Meilisearch Standard | 8 | 2 GB | 1.5 vCPU | 25 GB | 0 | 2 | 1 |
| Meilisearch Pro | 14 | 4 GB | 2 vCPU | 50 GB | 0 | 4 | 1 |

### Uptime Kuma

Uptime Kuma, self-hosted monitoring for your websites, APIs and servers: get alerted the moment something goes down and publish a status page for your users.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Uptime Kuma Starter | 4 | 1 GB | 0.5 vCPU | 5 GB | 0 | 1 | 1 |
| Uptime Kuma Standard | 7 | 2 GB | 1 vCPU | 10 GB | 0 | 2 | 1 |

### Vaultwarden

Your own Bitwarden-compatible password manager. Works with the official Bitwarden apps and browser extensions, with the web vault included. Needs a domain on the Proxy tab for HTTPS, which the panel issues for free.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Personal | 3 | 512 MB | 0.5 vCPU | 5 GB | 0 | 2 | 1 |
| Family | 5 | 1 GB | 1 vCPU | 10 GB | 0 | 3 | 1 |
| Team | 8 | 2 GB | 1.5 vCPU | 20 GB | 0 | 5 | 1 |

### Forgejo

Your own Git forge: repositories, issues, pull requests, wikis, packages and releases. Clone and push over HTTPS on your own domain.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 10 GB | 0 | 2 | 1 |
| Standard | 8 | 2 GB | 1.5 vCPU | 25 GB | 0 | 3 | 1 |
| Pro | 14 | 4 GB | 2 vCPU | 50 GB | 0 | 5 | 1 |

### NocoDB

An open-source Airtable alternative: build tables, forms, kanban boards and galleries without code, with a REST API over your data.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 5 | 1 GB | 1 vCPU | 10 GB | 0 | 2 | 1 |
| Standard | 9 | 2 GB | 1.5 vCPU | 20 GB | 0 | 3 | 1 |
| Pro | 15 | 4 GB | 2 vCPU | 40 GB | 0 | 5 | 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 |

### Jellyfin

Your own media server for films, shows and music: stream to a browser, phone or TV. Upload by SFTP; libraries are set up for you.

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

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | 9 | 2 GB | 2 vCPU | 50 GB | 0 | 1 | 1 |
| Standard | 16 | 4 GB | 3 vCPU | 100 GB | 0 | 2 | 1 |
| Pro | 24 | 6 GB | 3.5 vCPU | 150 GB | 0 | 3 | 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, crypto 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.
- Average Ping and Hops on the stats strip. Press Test now under the console and the round trip is measured from 12 countries; Hops counts the routers between the server and you, shown as ≥N when the last ones do not answer. Your address is not stored.

### 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, with buttons to open them in a new tab or download them.
- 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.
- Separately, every server is backed up every night, whatever its plan, outside your backup slots. Those are system backups: they are not on the Backups tab, and a restore from one goes through support.
- A game server's database is not inside its file archive, so it is dumped on its own every night.
- We pull copies off the node every day, so a backup does not live only on the machine it protects.

> 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.

- A database server - PostgreSQL, MongoDB, MySQL, SQL Server or Valkey - shows its host, port, username, password and connection string in a credentials card on its console page. Click any value to copy it; Show secrets reveals the passwords.
- SQL Server comes with a database already created and set as sa's default, so SSMS opens straight into it. The server name in SSMS is host,port - with a comma.

> 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.

- A pre-flight check card on the GitHub tab reads the repository before you link it and on every visit, and lists what will break, worst first, with a fix to paste - a missing start script, the wrong port or localhost, a missing build, a committed .env. It reads a few files; it never clones.
- Where starter templates are offered for your runtime, Use template opens GitHub's own new-repository page with a project that already binds the right port and passes the check. The panel creates nothing in your account.
- Switch on Keep AI context in the repository and the GitHub App opens a pull request adding .renode/server.md and a block in CLAUDE.md and AGENTS.md, so coding assistants read the server's rules. It commits only to its own branch; you merge. Public repositories get the rules only, never addresses or credentials.

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

## Tutorials

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

- [Ghost email setup: SMTP relay, Mailgun, SPF and DKIM](https://www.renode.space/blog/ghost-newsletters-and-smtp) (2026-10-19): How Ghost sends mail: SMTP for sign-in links and invites, Mailgun for bulk newsletters, the exact config keys, and the SPF and DKIM records that keep it out of spam.
- [Strapi S3 upload provider: custom endpoint, path-style](https://www.renode.space/blog/strapi-media-uploads-to-s3) (2026-10-19): Configure Strapi's aws-s3 upload provider for an S3-compatible endpoint: forcePathStyle, endpoint, region, ACLs, baseUrl, CSP and the errors you will hit.
- [Django Celery with Valkey: broker, beat and memory limits](https://www.renode.space/blog/django-celery-and-valkey) (2026-10-18): Run Celery in Django on a Valkey broker: connection settings, beat schedules, acks, visibility timeout, and worker memory and concurrency on a small plan.
- [Quarkus and Micronaut on a Java server: fast-jar and heap](https://www.renode.space/blog/quarkus-and-micronaut-on-a-java-server) (2026-10-18): Run Quarkus and Micronaut on a Java app server: the fast-jar layout, native vs JVM, heap flags for a small memory limit, ports, and health endpoints.
- [Gunicorn and uvicorn workers on a small server](https://www.renode.space/blog/python-gunicorn-and-uvicorn-workers-on-a-small-server) (2026-10-17): How many gunicorn or uvicorn workers a small plan can carry, plus timeouts, preload, HUP reloads, keepalive and max-requests, with a config that works.
- [AI context files in your repo: CLAUDE.md, AGENTS.md and the server](https://www.renode.space/blog/ai-context-file-in-your-repo) (2026-10-16): What CLAUDE.md and AGENTS.md are, what belongs in them, and how the panel can open a pull request that tells Claude Code and Cursor how your server runs.
- [Deploy a Java app: Spring Boot or plain jar on a Java server](https://www.renode.space/blog/deploy-a-java-app) (2026-10-16): Run a Spring Boot, Quarkus or plain Java jar on a Java server: Java version, building the jar, the port, heap vs memory limit, env vars, GitHub deploys, logs.
- [Deploy a Rust app with Cargo: build, port, env and GitHub](https://www.renode.space/blog/deploy-a-rust-app-with-cargo) (2026-10-16): Run an axum or actix-web service on Rust (Cargo) hosting: release builds, build time and memory per plan, the right port, env vars, GitHub deploys and logs.
- [Deploy Symfony to production on FrankenPHP](https://www.renode.space/blog/deploy-symfony-on-frankenphp) (2026-10-16): Take a Symfony app to production on FrankenPHP: APP_ENV, env files, Composer, cache warmup, Doctrine migrations, Messenger workers, trusted proxies.
- [Pre-flight check: catch a broken GitHub deploy before it runs](https://www.renode.space/blog/pre-flight-check-catch-broken-deploys) (2026-10-16): What the pre-flight check reads in a linked GitHub repo - start scripts, ports, builds, runtime versions, committed .env files - and how to fix each finding.
- [Starter templates: your first app on a new server in 5 minutes](https://www.renode.space/blog/starter-templates-first-app-in-5-minutes) (2026-10-16): Go from an empty app server to a running Express, Next.js, FastAPI, Discord or Telegram app from a GitHub template, then customise it and deploy on every push.
- [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.
- [Ghost blog hosting: install, mail, themes, backups and updates](https://www.renode.space/blog/ghost-blog-hosting-guide) (2026-10-15): Run a Ghost blog on your own app server: sizing, the url and mail settings, newsletters, themes, SQLite backups and updates that do not break the site.
- [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.
- [Strapi in production: env vars, database, uploads and upgrades](https://www.renode.space/blog/strapi-production-deployment-guide) (2026-10-15): Run Strapi in production: every secret in .env, SQLite or PostgreSQL, S3 uploads, the admin build, proxy and URL settings, backups and safe upgrades.
- [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.

---

### Ghost email setup: SMTP relay, Mailgun, SPF and DKIM

Published 2026-10-19, updated 2026-10-19 by RE:NODE Team. https://www.renode.space/blog/ghost-newsletters-and-smtp

Ghost sends email through two separate paths, and you configure them separately. One-to-one messages - member sign-in links, staff invitations, password resets - go out through the `mail` block of your configuration, normally over SMTP, and any SMTP relay will do. Bulk newsletters go through Mailgun, set up in Ghost Admin or in the `bulkEmail` block, and Ghost does not send them over SMTP at all. Whichever path carries the message, it only reaches an inbox if the sending domain publishes SPF and DKIM records that cover the service doing the sending. Most "Ghost email is broken" reports come down to one of those three things being missing.

This guide goes deeper on mail than the general [Ghost hosting guide](https://www.renode.space/blog/ghost-blog-hosting-guide): the exact keys, a worked relay setup, the Mailgun side, the DNS records, how to test, and what the errors mean.

#### Two kinds of mail, two paths

It helps to be strict about the vocabulary.

**Transactional mail** is triggered by one person doing one thing, and goes to that one person. In Ghost that means:

- The magic link a member receives to sign in or confirm a subscription. Ghost members have no password, so this link is the front door.
- Staff invitations and password resets for the admin area.
- Notification mails to staff, such as a new member signing up, when you have them switched on.

**Bulk mail** is the newsletter: one post, sent to every subscriber on a list, often thousands of messages in a few minutes.

The two have different needs. Transactional mail is small in volume but time-critical - a sign-in link that arrives after ten minutes has already lost the reader - so it needs a reliable submission server and good reputation. Bulk mail is high in volume and needs a service built for fan-out: batching, per-recipient tracking, unsubscribe handling and bounce processing. That is the job Ghost hands to Mailgun.

```diagram
title: Which path each kind of Ghost mail takes
ghost = Ghost | one Node.js process
smtp = SMTP relay | mail block
mailgun = Mailgun | bulkEmail or Admin
member = Member inbox | sign-in link
subs = Subscribers | newsletter
ghost -> smtp : transactional
ghost -> mailgun : newsletter
smtp -> member : one message
mailgun -> subs : batched send
```

Because the paths are independent, one can work while the other is broken. A site can send newsletters perfectly and still never deliver a sign-in link, and the reverse. Test both.

#### Why Ghost needs a relay for transactional mail

If you do not configure the `mail` block, Ghost falls back to delivering directly from the server itself. That means connecting to each recipient's mail server on port `25` from your server's IP address. On a typical hosting network this fails twice over. Many networks block outgoing port `25` to stop spam, and even where it is open, a fresh IP with no reverse DNS and no sending history is treated with suspicion by the big mailbox providers. The mail either never leaves or lands in spam.

An SMTP relay fixes both. Ghost connects to a relay on a submission port (`587` or `465`), logs in, and hands over the message. The relay delivers it from an address that already has a reputation, and signs or authenticates it for your domain. [SMTP relay for outgoing mail](https://www.renode.space/blog/smtp-relay-for-outgoing-mail) explains the general idea from the mail server's side; for Ghost it is simply "the thing the `mail` block points at".

Where the relay comes from is your choice:

- **A transactional email provider.** Services built for application mail give you an SMTP host, a username and a key, and a dashboard that shows what was delivered and what bounced. This is the usual answer. [Transactional email from your app](https://www.renode.space/blog/transactional-email-from-your-app) compares what to look for.
- **The outgoing server of an existing mailbox.** Works for a personal blog at low volume. Check the provider's daily sending limit, and note that some mailbox providers rewrite or restrict the `From` address.
- **Your own mail server.** RE:NODE sells a Mail Server line running Stalwart, with SMTP, IMAP and JMAP and a web admin for domains, mailboxes and DKIM. Ghost can submit to it like any other relay. [Stalwart mail server setup](https://www.renode.space/blog/stalwart-mail-server-setup) covers the server; [self-hosted mail server guide](https://www.renode.space/blog/self-hosted-mail-server-guide) is honest about what running one involves.
- **Mailgun's SMTP endpoint.** Since you may be creating a Mailgun account for newsletters anyway, you can use its SMTP credentials for transactional mail as well. That keeps everything on one dashboard, at the cost of one vendor for everything.

Ghost only ever connects out to a submission port. It never needs port `25`, and it never receives mail.

#### The mail block, key by key

Ghost reads its configuration from `config.production.json` in the install folder. The `mail` block looks like this for a typical relay:

config.production.json:

```json
{
  "mail": {
    "from": "'Example Blog' <noreply@example.com>",
    "transport": "SMTP",
    "options": {
      "host": "smtp.relay.example",
      "port": 587,
      "secure": false,
      "auth": {
        "user": "smtp-username",
        "pass": "smtp-password"
      }
    }
  }
}
```

| Key | Example | Meaning |
| --- | --- | --- |
| `mail.transport` | `SMTP` | Use an SMTP server rather than direct delivery |
| `mail.from` | `'Name' <noreply@example.com>` | The default sender address |
| `mail.options.host` | `smtp.relay.example` | The relay hostname |
| `mail.options.port` | `587` or `465` | Submission port |
| `mail.options.secure` | `false` or `true` | `true` only for port `465` |
| `mail.options.auth.user` | login | SMTP username |
| `mail.options.auth.pass` | password or key | SMTP password |

A few details cause most of the trouble.

**Port and `secure` go together.** Port `465` uses TLS from the first byte, so it needs `"secure": true`. Port `587` starts in plain text and upgrades with STARTTLS, so it wants `"secure": false` - the upgrade still happens, the flag only describes the very first byte. Mixing them gives a timeout or a TLS handshake error, not a helpful message.

**The `from` address is a promise.** The relay will usually only send as addresses or domains you have verified with it, and receivers compare that address to your SPF and DKIM records. Use an address on a domain you control and have set up with the relay. `noreply@` is fine for transactional mail, but a real, monitored address is kinder to a reader who replies to a sign-in email.

**Quote the display name.** The single quotes inside the JSON string in `from` are deliberate: `"'Example Blog' <noreply@example.com>"` keeps the name and the address together. A bare `Example Blog <noreply@example.com>` also works in most versions, but the quoted form is the one Ghost's own documentation shows.

**Some providers have a named service.** The underlying mail library accepts `options.service` with a well-known provider name instead of host and port, and Ghost's own documentation shows this for Mailgun. If your provider documents a service name, it saves you from typing the host. When in doubt, spell out `host`, `port` and `secure`, which always work.

##### Setting it without editing the file

On a machine you manage with Ghost-CLI, you can set a key from the shell, and Ghost-CLI writes it into the config file for you:

```bash
$ ghost config mail.transport SMTP
$ ghost config mail.options.host smtp.relay.example
$ ghost config mail.options.port 587
$ ghost restart
```

Ghost also reads environment variables, with a double underscore standing in for each level of nesting, so `mail__transport` sets `mail.transport`:

```env
mail__transport=SMTP
mail__from='Example Blog' <noreply@example.com>
mail__options__host=smtp.relay.example
mail__options__port=587
mail__options__secure=false
mail__options__auth__user=smtp-username
mail__options__auth__pass=smtp-password
```

Environment variables win over the file. That is useful here for a specific reason: an SMTP password is a secret, and keeping it in an environment variable rather than in a file you might paste into a forum or commit to Git is a good habit. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the habit in general. If an edit to the file seems to do nothing, check whether an environment variable is overriding it, and remember that Ghost reads configuration only when it starts.

On a RE:NODE Ghost server, the config file is an ordinary file in the file manager and over SFTP, and the Startup tab holds environment variables. Either route works; use the one you will remember. Restart the server after any change.

#### Newsletters and Mailgun

Newsletter sending is a different mechanism. Ghost takes a published post, renders it into an email, splits your subscriber list into batches, and submits those batches to the Mailgun API. Mailgun does the actual delivery to the inboxes, reports bounces and complaints back to Ghost, and Ghost uses that to mark addresses as undeliverable.

There are two ways to configure it.

**In Ghost Admin.** Under the settings for the newsletter email, you enter your Mailgun region, the sending domain and a Mailgun private API key. This is the normal route and what Ghost's own documentation describes for self-hosted sites.

**In the configuration file.** A `bulkEmail` block holds the same three values, and Ghost's documentation lists it as the way to set Mailgun from config:

config.production.json:

```json
{
  "bulkEmail": {
    "mailgun": {
      "baseUrl": "https://api.mailgun.net/v3",
      "apiKey": "your-mailgun-private-key",
      "domain": "mg.example.com"
    }
  }
}
```

| Key | Example | Meaning |
| --- | --- | --- |
| `bulkEmail.mailgun.baseUrl` | `https://api.mailgun.net/v3` | The Mailgun API for your region |
| `bulkEmail.mailgun.apiKey` | private key | Authorises Ghost to send |
| `bulkEmail.mailgun.domain` | `mg.example.com` | The sending domain registered at Mailgun |

Mailgun has a US and an EU region, and an account lives in one of them. A domain created in the EU region needs `https://api.eu.mailgun.net/v3` as the base URL; using the US address for an EU domain fails with an authorisation error that looks like a wrong key but is a wrong region. When both are set, the config file takes precedence over what is saved in the admin, which can be confusing if someone changes one and not the other.

The sending domain is usually a subdomain such as `mg.example.com` or `news.example.com` rather than your main domain. This keeps the DNS records Mailgun asks for separate from your normal mail, and means a deliverability problem with newsletters does not drag down the address you write business email from. The address newsletters appear to come from can still be on the main domain; Ghost lets you set a sender name and address per newsletter, subject to what Mailgun will allow for that domain.

> [!NOTE]
> Mailgun's pricing and free allowance have changed more than once. Check its current plans before you promise a free newsletter to anyone, and look at what happens to your list size as it grows. If your whole publication is a newsletter, the email service is the largest recurring cost, not the server.

Before you rely on it, send a test. Ghost's newsletter editor can send a preview to your own address, which proves the API key, the region and the domain agree. Then send a real post to a small segment, such as a label you have given to a handful of friendly members, before the first full send.

##### Do you actually need Mailgun?

Only if you want to email posts. A site that publishes to the web, lets members sign in and never sends a newsletter needs only the `mail` block. If you are not ready to set up Mailgun, you can still collect subscribers and send the newsletter later. What you cannot do is replace Mailgun with your SMTP relay for bulk sends: pointing the `mail` block at a relay does not enable newsletter delivery, however generous the relay's limits are.

#### SPF and DKIM for a Ghost domain

Mail from your domain is checked by the receiving server against DNS records you publish. Two matter most for Ghost, and a third ties them together.

**SPF** is a TXT record on the domain that lists which servers may send mail for it. A receiver looks at the envelope sender, fetches that domain's SPF record, and checks the connecting IP against it.

**DKIM** is a cryptographic signature. The sending service signs each message with a private key; you publish the matching public key in a TXT record at `selector._domainkey.yourdomain`. The receiver fetches the key and verifies the signature, which proves the message was not altered and that someone with the key sent it.

**DMARC** says what to do when checks fail, and requires that the domain which passed SPF or DKIM matches the visible `From` domain. This is called alignment, and it is the reason "SPF passes" is not always enough. [SPF, DKIM and DMARC explained](https://www.renode.space/blog/email-spf-dkim-dmarc-explained) goes through all three in order.

Because Ghost uses two services, you usually end up with records for both:

| Name | Type | Purpose |
| --- | --- | --- |
| `example.com` | TXT | SPF for the transactional relay |
| `selector._domainkey.example.com` | TXT | DKIM key for the relay |
| `mg.example.com` | TXT | SPF for Mailgun |
| `krs._domainkey.mg.example.com` | TXT | DKIM key for Mailgun |
| `_dmarc.example.com` | TXT | Policy and report address |

The exact selector names and key values come from each provider's dashboard; do not guess them. The `krs` above is only an example of the shape - copy what Mailgun shows you.

A minimal SPF record that authorises one relay looks like this:

TXT record on example.com:

```text
v=spf1 include:_spf.relay.example ~all
```

Rules worth knowing:

1. **One SPF record per name.** If you already have one, merge the new `include:` into it. Two SPF records on the same name make the check fail with a permanent error.
2. **Ten DNS lookups at most.** Each `include:` costs at least one, and nested includes cost more. Pile on services and you cross the limit, after which SPF fails for everything.
3. **`~all` versus `-all`.** `~all` means "soft fail anything not listed" and is the safe starting point. Switch to `-all` once you are sure every sender is listed.
4. **A separate sending domain gets its own SPF.** If Mailgun sends from `mg.example.com`, the SPF record Mailgun asks for goes on `mg.example.com`, not on the root.

The DMARC record starts in monitoring mode:

TXT record on _dmarc.example.com:

```text
v=DMARC1; p=none; rua=mailto:dmarc@example.com
```

`p=none` changes nothing about delivery; it only asks receivers to send you aggregate reports so you can see who is sending as your domain and whether they pass. Read those reports for a few weeks, fix anything legitimate that fails, then tighten the policy. [DMARC reports and policy rollout](https://www.renode.space/blog/dmarc-reports-and-policy-rollout) walks through that sequence, and [DKIM keys and rotation](https://www.renode.space/blog/dkim-keys-and-rotation) covers key sizes and replacing a key later.

Add records in your DNS provider, wait for them to propagate, and use the verify button in the relay's and Mailgun's dashboards. Both will refuse to call a domain verified until the records resolve. [DNS records explained](https://www.renode.space/blog/dns-records-explained) is the primer if TXT, CNAME and TTL are new to you. Some providers ask for CNAME records instead of TXT for DKIM; that is a delegation to their key and is fine, as long as you copy the name and target exactly. Many DNS panels append your domain automatically, so a name you paste in full can end up doubled as `selector._domainkey.example.com.example.com`. Look at the saved record, not what you typed.

#### Testing and reading the failure

Test in this order, because each step depends on the one before it.

1. **Restart Ghost** so it reads the new configuration.
2. **Trigger a real transactional mail.** In a private browser window, sign up as a member with an address you can read. The sign-in or confirmation link should arrive within seconds. Alternatively, invite a staff user to an address of yours.
3. **Look at the raw message headers** in your mail client ("show original"). You want to see `spf=pass`, `dkim=pass` and `dmarc=pass` in the `Authentication-Results` header. A pass for SPF with a fail for DMARC usually means alignment: the relay passed SPF for its own domain, not yours, and there is no DKIM signature for yours.
4. **Send a newsletter preview** and check the same headers. The Mailgun side has its own domain, so the result may differ from step 3.
5. **Send to more than one provider.** A mailbox at a large provider and one at a small host disagree often enough to be worth the extra minute.

> [!WARNING]
> Test the sign-in link before you announce the site. If transactional mail is broken, readers see a portal that appears to accept their address and then silently never sends anything. Nobody will tell you; they just leave.

The Ghost log is the first place to look when nothing arrives. Errors from the mail layer mention the SMTP response from the relay, which is far more specific than anything shown in the browser. Read the log line before changing settings.

#### Common errors and what they mean

**Nothing arrives, no error in the log.** Ghost is still on direct delivery or the `mail` block was not read. Check `mail.transport` is `SMTP`, confirm there is no environment variable overriding it, and restart.

**Connection timeout.** The relay host or port is wrong, or the network blocks the port. If the network blocks outgoing port `25`, that is a reason to use `587` or `465` to a relay rather than anything to do with Ghost. [Port 25 and outbound mail blocks](https://www.renode.space/blog/port-25-and-outbound-mail-blocks) explains the difference between the ports.

**TLS or "wrong version number" errors.** Port and `secure` disagree. `465` needs `true`; `587` needs `false`.

**Authentication failed.** The username or password is wrong, or the relay needs an application key rather than your account password. Some providers use the literal word `apikey` as the username and the key as the password. Read the relay's SMTP page rather than assuming.

**Sender address rejected.** The `from` address or its domain is not verified with the relay. Verify the domain there, then use an address on it.

**Mail arrives but lands in spam.** Records are missing or do not align. Check the headers as above. Also check that the domain does not have two SPF records and that DKIM is switched on in the relay, not only published in DNS.

**Newsletter option greyed out, or a Mailgun error on send.** The API key, region or domain is wrong or unset. Check the `baseUrl` region first; a mismatch looks like a bad key.

**Newsletter sends but members report nothing.** Mailgun accepted the batch; delivery then failed or was filtered. Look at Mailgun's logs for the recipient, which show whether the message was delivered, bounced or marked as spam.

**Sign-in links work for some addresses and not others.** Often a receiver that rejects mail on SPF or DKIM failure while others are lenient. Fix the records rather than blaming the recipient's provider.

#### Running Ghost on a RE:NODE app server

Ghost is one of the one-click app lines. The server installs and builds Ghost on its first start and keeps its data in SQLite on the server, so mail is the main thing left to arrange. You edit `config.production.json` in the file manager or over SFTP, or set environment variables on the Startup tab, and restart from the console. [One-click apps: WordPress, Ghost and Strapi](https://www.renode.space/blog/one-click-apps-wordpress-ghost-strapi) describes what the installer does.

For the relay you have three honest options: a transactional provider, Mailgun's own SMTP, or the Stalwart Mail Server line if you want to run mail yourself. The Mail Server line has a documented way to deal with a network that blocks outgoing port `25`: you set a relay in its web admin. You add the MX, SPF, DKIM and DMARC records yourself in either case. Receiving mail from the internet on that server needs port `25` forwarded by support, but Ghost does not need to receive anything - it only submits.

#### FAQ

##### Can I send Ghost newsletters over SMTP instead of Mailgun?

No. Ghost's bulk email path is built on the Mailgun API. The SMTP settings carry transactional mail only, so a relay will deliver sign-in links but will not enable the option to email a post.

##### Which port should I use for Ghost SMTP, 465 or 587?

Either, as long as `secure` matches: `465` with `"secure": true`, or `587` with `"secure": false`. Use whichever your relay recommends. Avoid port `25`; it is for server-to-server delivery and is commonly blocked.

##### Why does a sign-in link go to spam when SPF passes?

Because DMARC checks alignment, not just a pass. If SPF passes for the relay's own domain and nothing is DKIM-signed for yours, the `From` domain does not align. Turn on DKIM for your domain in the relay and publish the key it gives you.

##### Do I need a separate domain for newsletters?

No, but a subdomain such as `mg.example.com` is the usual pattern. It keeps Mailgun's DNS records apart from your normal mail and protects your main domain's reputation if a send goes badly.

##### Where should the SMTP password live?

In an environment variable such as `mail__options__auth__pass` rather than in a file you might share. Either way it is read at start, so restart Ghost after changing it. Rotate it if it ever appears in a log or a screenshot.

##### Is it worth running my own mail server just for Ghost?

Usually not. Ghost sends little transactional mail, and a good relay gives you reputation and a delivery dashboard that a new server takes weeks to earn. Run your own mail if you want mailboxes you control, not to send a few sign-in links.

Ghost on RE:NODE installs and builds on the server's first start and keeps its data in SQLite on the server, and you edit `config.production.json` or set environment variables yourself. Plans start from $5 a month, and the Mail Server line is there if you want to run the SMTP side too.

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

---

### Strapi S3 upload provider: custom endpoint, path-style

Published 2026-10-19, updated 2026-10-19 by RE:NODE Team. https://www.renode.space/blog/strapi-media-uploads-to-s3

To send Strapi's media uploads to an S3-compatible store that is not Amazon, install `@strapi/provider-upload-aws-s3`, set `provider: 'aws-s3'` in `config/plugins.js`, and put four things under `providerOptions.s3Options`: the credentials, a region name, the store's `endpoint`, and `forcePathStyle: true`. Add `params.Bucket` for the bucket and `baseUrl` for the address browsers will load files from. That is the whole configuration. Nearly every failure afterwards comes from one of three places: the addressing style (the SDK builds a hostname that does not exist), the public URL (files upload but do not display), or the access control list sent with each upload (the store refuses it). This post sets the provider up properly, explains each key, and then works through the errors in the order you are likely to meet them.

It assumes Strapi is already running. For the surrounding production decisions - secrets, database, admin build, upgrades - see [Strapi in production](https://www.renode.space/blog/strapi-production-deployment-guide), which covers the same provider in one section. This post is the long version of that section.

#### What the upload provider does

Strapi's Upload plugin is the code behind the Media Library and the `/api/upload` endpoint. The plugin does not store files itself. It hands each file to a provider, a small package with three jobs: upload a buffer, upload a stream, and delete. The default provider writes to `public/uploads` on the server's disk. The S3 provider writes to a bucket instead.

The path a file takes matters for troubleshooting, so it is worth having in your head:

1. The editor drops a file into the Media Library. The browser sends it to Strapi, not to the bucket.
2. Strapi receives the multipart request, reads the file, and for images generates the extra sizes (`thumbnail_`, `small_`, `medium_` and `large_` variants, depending on your breakpoints).
3. For each file and each variant, the provider makes one S3 `PutObject` request from the Strapi process to the endpoint.
4. Strapi stores the resulting public URL in its database, in the `url` field of the file record.
5. When a page or the admin shows the image, the browser loads that URL directly from the storage. Strapi is not in the path.

```diagram
title: Uploads go through Strapi, reads go straight to storage
editor = Editor | Media Library
strapi = Strapi | provider aws-s3
store = S3 endpoint | path-style
visitor = Visitor | browser
editor -> strapi : multipart upload
strapi -> store : PutObject
strapi --> visitor : file URL in the API
visitor -> store : GET baseUrl/key
```

Two consequences follow. First, uploads need no CORS configuration on the bucket, because the browser never writes to it; CORS only matters if you later add direct browser uploads with presigned URLs (see [S3 presigned URLs](https://www.renode.space/blog/s3-presigned-urls-for-uploads)). Second, the server that runs Strapi needs to reach the storage endpoint, while your visitors need to reach whatever `baseUrl` points at. Those can be two different addresses, and keeping them straight is most of the configuration.

#### What you need from the storage

Before touching Strapi, collect five values from your S3-compatible store:

| Value | Example | Used for |
| --- | --- | --- |
| Endpoint URL | `https://s3.example.com` | `endpoint` |
| Access key and secret | `AKIA...` and a long string | `credentials` |
| Bucket name | `strapi-media` | `params.Bucket` |
| Region name | `us-east-1` | `region` |
| Public base URL | `https://s3.example.com/strapi-media` | `baseUrl` |

The region is the one people stall on. Amazon uses it to pick a data centre and to build the request signature. A store that is not Amazon has no region to pick, but the SDK still insists on a value because the signature includes one. The rule is to use whatever the store says, and if it says nothing, any well-formed name such as `us-east-1` works on most of them. What matters is that the same string is used everywhere the store is accessed. [S3 path-style vs virtual-hosted URLs, and regions explained](https://www.renode.space/blog/s3-path-style-vs-virtual-hosted) goes through why.

Test the values with something other than Strapi first. If the AWS CLI cannot list the bucket, Strapi will not manage it either, and the CLI gives a much clearer error. Tell the CLI to use path-style addressing, then list:

```bash
$ aws configure set default.s3.addressing_style path
$ export AWS_ACCESS_KEY_ID=...
$ export AWS_SECRET_ACCESS_KEY=...
$ aws --endpoint-url https://s3.example.com --region us-east-1 s3 ls s3://strapi-media/
```

An empty bucket prints nothing and exits cleanly, which is a pass. A wrong key prints `InvalidAccessKeyId` or `SignatureDoesNotMatch`; a wrong bucket prints `NoSuchBucket`. [S3 storage with the AWS CLI](https://www.renode.space/blog/s3-storage-with-aws-cli) has the full set of commands if you want to go further.

#### Install the provider

From the Strapi project folder:

```bash
$ npm install @strapi/provider-upload-aws-s3
```

Despite the name, this is the provider to use for any S3-compatible service. There is no separate "generic S3" package from Strapi. Community providers exist for specific vendors, but for an endpoint you control the official one is the better choice, because it is the one that tracks Strapi releases. Match its major version to your Strapi major version; the package is published with the same versioning as Strapi itself, and a provider from an older major is a common cause of confusing errors after an upgrade. Check the installed version with `npm ls @strapi/provider-upload-aws-s3` and compare it to the `@strapi/strapi` version in `package.json`.

#### The configuration, key by key

Create or edit `config/plugins.js` (or `config/plugins.ts` in a TypeScript project, which has the same structure with `export default`):

config/plugins.js:

```js
module.exports = ({ env }) => ({
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        baseUrl: env('S3_PUBLIC_URL'),
        rootPath: env('S3_ROOT_PATH', ''),
        s3Options: {
          credentials: {
            accessKeyId: env('S3_ACCESS_KEY_ID'),
            secretAccessKey: env('S3_SECRET_ACCESS_KEY'),
          },
          region: env('S3_REGION', 'us-east-1'),
          endpoint: env('S3_ENDPOINT'),
          forcePathStyle: true,
          params: {
            Bucket: env('S3_BUCKET'),
          },
        },
      },
      actionOptions: {
        upload: {},
        uploadStream: {},
        delete: {},
      },
    },
  },
});
```

The names inside `env()` are yours to choose; the structure around them is fixed. Here is what each key does:

| Key | What it does |
| --- | --- |
| `provider` | Must be `'aws-s3'`, the short name of the package |
| `providerOptions.baseUrl` | The address stored in the database and used by browsers |
| `providerOptions.rootPath` | A prefix, so several apps can share a bucket |
| `s3Options.credentials` | Access key and secret, as `accessKeyId` and `secretAccessKey` |
| `s3Options.region` | Required by the SDK even when the store ignores it |
| `s3Options.endpoint` | The store's URL; without it the SDK talks to Amazon |
| `s3Options.forcePathStyle` | Bucket in the path, not in the hostname |
| `s3Options.params.Bucket` | The bucket every object goes into |
| `actionOptions` | Extra parameters passed to each `upload`, `uploadStream` or `delete` call |

Everything under `s3Options` is passed to the AWS SDK for JavaScript v3 `S3Client` constructor, which is why the names match the SDK's own documentation. That is useful to know: any option the SDK client accepts can go there, including the checksum options covered below.

> [!WARNING]
> Many tutorials show `accessKeyId`, `secretAccessKey`, `region` and `s3ForcePathStyle` directly under `providerOptions`. That is the older layout, built on version 2 of the AWS SDK. Current provider versions read `s3Options` and `forcePathStyle`, and ignore the old names silently - so a copy-pasted config runs, then talks to Amazon with no credentials. If your config does not have `s3Options`, it is the old one.

`rootPath` deserves a word. Without it, objects land at the bucket's root with a generated key like `thumbnail_cat_4f2a1b.jpg`. With `rootPath: 'cms'`, the key becomes `cms/thumbnail_cat_4f2a1b.jpg`. It is worth setting if the bucket is shared with anything else, and it costs nothing to set on day one. Changing it later does not move existing objects, and the stored URLs keep pointing at the old keys.

#### The environment variables

Keep the credentials out of the config file and in `.env` in the project folder, next to the Strapi secrets:

.env:

```env
S3_ENDPOINT=https://s3.example.com
S3_REGION=us-east-1
S3_BUCKET=strapi-media
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_URL=https://s3.example.com/strapi-media
S3_ROOT_PATH=
```

Never commit this file. Strapi reads `.env` on start, so after editing it restart the server; no admin rebuild is needed for these values, because they are used by the server process and not by the browser bundle. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the habits that keep a `.env` out of a repository. If you host Strapi on RE:NODE, the file manager and SFTP both reach the project folder, so the file can be edited in the browser - see [SFTP and the file manager](https://www.renode.space/blog/sftp-and-the-file-manager).

One detail that catches people: an empty value such as `S3_ROOT_PATH=` is an empty string, and `env('S3_ROOT_PATH', '')` returns it as such. That is fine here. But an empty `S3_ENDPOINT=` is also an empty string, which the SDK treats as no endpoint, and it quietly sends your requests to Amazon. If uploads fail with an `InvalidAccessKeyId` message from a store that has never heard of your key, check that the variable is actually set and spelled the way the config reads it.

#### Why path-style, and what happens without it

Given an endpoint and a bucket, the AWS SDK builds one of two URLs. Virtual-hosted style, the default, puts the bucket in the hostname: `https://strapi-media.s3.example.com/cat.jpg`. Path-style puts it in the path: `https://s3.example.com/strapi-media/cat.jpg`. Virtual-hosted needs a DNS record for every bucket name, and a certificate that covers them. A store that sits behind one hostname has neither, so the SDK's default produces a name that does not resolve.

The failure is distinctive. If `forcePathStyle` is missing or false, the Strapi log shows something like this on the first upload:

```text
error: getaddrinfo ENOTFOUND strapi-media.s3.example.com
```

Look at the hostname in the message. If it starts with your bucket name, the SDK is using virtual-hosted style, and `forcePathStyle: true` in `s3Options` is the fix. If the hostname is plain `s3.example.com` and the error is a different one, addressing is correct and the problem is elsewhere. This one check removes half of the guessing.

There is a subtler version. A store that has wildcard DNS and a wildcard certificate may accept virtual-hosted requests, and uploads will work without the setting. If you later move to a store that does not, the same configuration breaks. Set `forcePathStyle: true` regardless; path-style works on every S3-compatible store, including Amazon's, which still accepts it for existing buckets.

#### baseUrl: why files upload but do not show

The provider writes objects through `endpoint`, but the URL it stores in the database is built from `baseUrl`. The two are separate on purpose, since the address you write to is often not the address you want the world to read from. A CDN, a proxy with a nicer hostname, or a public path on the same host are all common.

If `baseUrl` is not set, the provider builds the URL from the SDK's idea of the bucket location. With a custom endpoint and path-style addressing that is usually the endpoint plus the bucket, which can be correct, but it can also be an address that only the Strapi server can reach, such as an internal or plain-HTTP one. Setting `baseUrl` explicitly removes the guesswork.

For path-style addressing the base URL has the bucket in it: `https://s3.example.com/strapi-media`. The stored URL is then the base plus the key, so a file stored as `thumbnail_cat_4f2a1b.jpg` is served from `https://s3.example.com/strapi-media/thumbnail_cat_4f2a1b.jpg`. Open that address in a private browser window, with no credentials, and see whether the image loads. That one test separates the three common failures:

- **The browser warns about mixed content, or blocks the image.** The admin panel is on HTTPS and the stored URL is plain `http://`. Browsers block HTTP images on an HTTPS page. Use an HTTPS name in `baseUrl`.
- **The URL returns `403` or `AccessDenied`.** The object exists but anonymous reads are not allowed. See the next section.
- **The URL returns `404` or `NoSuchKey`.** The URL does not match the key. Check `rootPath` and whether the bucket name is in `baseUrl`.

The Media Library has a fourth, Strapi-specific way to fail: the files load in a new tab, but the thumbnails in the admin panel are broken. That is Strapi's own Content Security Policy refusing images from a host it was not told about, and the fix lives in `config/middlewares.js`. The [Strapi production guide](https://www.renode.space/blog/strapi-production-deployment-guide) has the exact `strapi::security` block with `img-src` and `media-src`; add the host of your `baseUrl` to both.

#### Public files, private files and ACLs

By default the provider uploads every object with a public-read ACL, which is what lets `baseUrl` links work for anonymous visitors. That is the right behaviour for a marketing site's images. It is the wrong behaviour for anything that should not be public, and it can also be rejected by the store.

An ACL is a per-object permission header sent along with `PutObject`. Amazon has been moving away from them: new Amazon buckets block ACLs by default and rely on bucket policies. Some S3-compatible stores never implemented ACLs at all, and answer an upload that carries one with `AccessControlListNotSupported` or a generic `NotImplemented`. If your uploads fail with an ACL complaint and the credentials are fine, that is the cause. The provider reads the ACL from `params`, so you control it there:

config/plugins.js (the s3Options params block):

```js
params: {
  ACL: 'private',
  signedUrlExpires: 15 * 60,
  Bucket: env('S3_BUCKET'),
},
```

With `ACL: 'private'`, objects are not readable anonymously and the provider switches to signed URLs: when Strapi returns a file in an API response, it signs a time-limited URL and returns that instead of the plain one. `signedUrlExpires` is the lifetime in seconds. Two practical notes:

- Signed URLs expire, so a page that caches an API response for longer than `signedUrlExpires` will show broken images. Match the cache lifetime to the signature lifetime, or keep the bucket public for assets that belong on public pages.
- The signature covers the hostname. The signed URL is built from the `endpoint`, so that endpoint has to be one the visitor's browser can reach. An internal address that works for the Strapi server will sign URLs that nobody else can open. [S3 presigned URLs](https://www.renode.space/blog/s3-presigned-urls-for-uploads) explains why.

Whether a given store can make a bucket or a path readable without ACLs is a property of that store, not of Strapi: some use bucket policies, some have a "public" switch per bucket, some have neither. Check what yours supports, and test anonymous access with `curl -I` on an uploaded file before you tell editors it is ready. The same applies to a store that rejects ACLs: if you cannot send a public-read ACL and cannot set a policy, signed URLs with `ACL: 'private'` are the working route. Check the provider's README for the exact handling of a null or omitted ACL in your installed version, because that behaviour has changed between releases.

#### Upload checksums with newer SDK versions

The provider is built on the AWS SDK for JavaScript v3. Since early 2025 the SDK adds a CRC-based integrity checksum to uploads by default. Some S3-compatible servers do not accept those extra headers, and the failure looks odd: the Media Library lists objects fine, deletes fine, and every upload fails with `SignatureDoesNotMatch`, `MissingContentLength` or a complaint about checksums or `x-amz-content-sha256`.

The SDK client has two options that turn the new behaviour down to "only when the operation requires it". Because `s3Options` is passed straight to the client, they go there:

config/plugins.js (inside s3Options):

```js
s3Options: {
  credentials: { /* ... */ },
  region: env('S3_REGION', 'us-east-1'),
  endpoint: env('S3_ENDPOINT'),
  forcePathStyle: true,
  requestChecksumCalculation: 'WHEN_REQUIRED',
  responseChecksumValidation: 'WHEN_REQUIRED',
  params: { Bucket: env('S3_BUCKET') },
},
```

Only add these if you see that symptom: they exist in SDK releases from early 2025 onward, and an older SDK ignores unknown options. If your installed provider pulls an older SDK, upgrading the provider is the cleaner fix. The same issue appears with the command line client and with other SDKs, and [S3 from Node.js and Python](https://www.renode.space/blog/s3-from-node-and-python) shows the equivalent settings there. A useful tell: if `aws s3 cp` works against the endpoint and Strapi does not, compare the CLI and SDK versions before anything else.

#### Testing, and checking what actually happened

With the config in place, restart Strapi and upload one image through the Media Library. Do these checks in order:

1. **The server log.** Start Strapi in a terminal, or read the panel console. Any provider error is logged at the moment of the upload, with the real message. Do not trust the admin's generic "Internal Server Error" toast.
2. **The bucket.** List it with the CLI. You should see the original and, for images, the `thumbnail_`, `small_`, `medium_` and `large_` variants, each as a separate object. Use `aws --endpoint-url https://s3.example.com s3 ls s3://strapi-media/` as before.
3. **The URL.** Click the file in the Media Library, copy its link, and open it in a private window.
4. **The delete.** Delete the test file in the admin and list again. The provider's `delete` call removes the original and every variant; if objects remain, the credentials probably lack permission to delete, which is easy to miss when a key was created with write-only access.

Step 4 is the one people skip. A key that can write but not delete leaves orphans in the bucket that nobody sees until the bill, or the quota, shows them.

#### Moving the files you already have

Changing provider does not move anything. Files uploaded earlier still have `/uploads/...` URLs in the database and still live in `public/uploads`. There are three honest options:

- **Leave them.** Keep `public/uploads` on the server's disk and let new files go to the bucket. Old links keep working as long as the folder is there. It is the lowest-risk choice, and it is fine for a small library.
- **Copy and rewrite.** Sync `public/uploads` into the bucket with a tool such as `rclone` or the AWS CLI (see [rclone with S3 storage](https://www.renode.space/blog/rclone-with-s3-storage)), then update the `url` (and the per-format URLs in the `formats` JSON column) in the `files` table to the new base. This is a database change, so take a Strapi backup first and test it on a copy. Query the real column names from your own schema rather than copying them from a tutorial.
- **Export and import.** Strapi's `strapi export` and `strapi import` commands move content and, with the right flags, media. They are the supported route for moving between environments, but check that your version's import writes through the configured provider before relying on it for a migration.

Whichever you choose, do it in a quiet hour and keep a backup of both the database and `public/uploads` until you have looked at the result in the admin. A migration that is only tested by uploading a new image has not tested the old ones.

#### Keeping the bucket and the database in step

Once uploads live in a bucket, the database and the bucket become two things that must agree. Backing up one without the other gives you either records with no files or files with no records.

- A Strapi backup (a database copy, or `strapi export`) holds the references. It does not hold the images.
- A copy of the bucket holds the images. It does not know which are used.
- Restore both from the same day, or accept that some records will point at missing files.

The bucket has its own durability to think about. If the store keeps one copy in one place, it is not a backup of anything; it is the primary copy, and it needs its own backup. Read [the 3-2-1 rule for S3 storage](https://www.renode.space/blog/s3-storage-sizing-and-the-3-2-1-rule) for how to size that, and [restic backups to S3](https://www.renode.space/blog/restic-backups-to-s3) for a way to take it.

#### Strapi with an S3 plan on RE:NODE

On RE:NODE, Strapi is a one-click app on the [app hosting](https://www.renode.space/app-hosting) page, installed and built on the server's first start with data in SQLite on the server. An [S3 object storage](https://www.renode.space/object-storage) plan is a separate server that provides what this post needs: an S3-compatible endpoint, an access key and secret key generated for that server, and a first bucket already created. It uses path-style addressing and accepts any region name, so `forcePathStyle: true` and a region such as `us-east-1` are the right values. The endpoint answers plain HTTP on its own port, and HTTPS goes through the plan's proxy slot: point a hostname at the address shown and the certificate is issued and renewed for you. Use that HTTPS name in `endpoint` and `baseUrl`, or the admin will block the images as mixed content.

The S3 plans do not include backup slots, and the storage is one copy on NVMe in one location, not replicated. That is fine for media you can re-create, and a reason to copy anything irreplaceable somewhere else. The Strapi server's own backup slots cover the project folder and SQLite file, not the bucket. Whether anonymous reads work on the bucket and how to enable them is not something this post can promise for you; test an uploaded file in a private window as described above.

#### Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| `ENOTFOUND bucket.host` | `forcePathStyle` missing or in the wrong place |
| `InvalidAccessKeyId` from your own store | Endpoint empty, so the request went to Amazon |
| `NoSuchBucket` | Wrong `params.Bucket`, or the bucket is not created |
| `AccessControlListNotSupported` | The store rejects ACLs; set `ACL: 'private'` |
| `SignatureDoesNotMatch` on upload only | New default checksums; see the checksum section |
| `SignatureDoesNotMatch` everywhere | Wrong secret, or a proxy rewrote the `Host` header |
| Uploads work, thumbnails broken | `img-src` in `config/middlewares.js` |
| Images blocked in the admin | `baseUrl` is `http://` on an HTTPS admin |
| `413` on upload | Reverse proxy body limit, not Strapi or the store |
| `403` on the public URL | Object not publicly readable |

A few notes that do not fit in a table. `SignatureDoesNotMatch` on every request means the credentials are wrong or the signature is being broken in transit; the usual in-transit culprit is a reverse proxy in front of the store that does not forward the original `Host` header, since the signature covers it. If it only happens on uploads, think about checksums before credentials.

For a `413`, the limit is a setting of whatever proxy sits in front of Strapi. Uploads from the admin go through it. Raise the body size there, and consider Strapi's own `sizeLimit` under the upload plugin's `config` so editors get a clear message instead of a dropped connection.

If Strapi fails to start after the change, with a message that the provider cannot be loaded, the package is not installed in the folder Strapi runs from: run `npm install` there, not on your laptop, and restart. And remember that a change to `config/plugins.js` needs a restart, but changes to the admin's Content Security Policy in `config/middlewares.js` need a restart too; neither needs a rebuild.

#### FAQ

##### Do I need CORS configured on the bucket for Strapi uploads?

No. Uploads go from the browser to Strapi and from Strapi to the bucket, so the browser never writes to the bucket and CORS does not apply. You need CORS only if you add direct browser uploads with presigned URLs, or load fonts or canvas data from the bucket.

##### Which region name should I use for a non-Amazon store?

Use the one the store documents. If it documents none, use `us-east-1`. The SDK needs some value to sign requests with, and a store that is not Amazon accepts whatever it was told to expect. Keep the same string in Strapi, the CLI and every other client.

##### Can I use the S3 provider with a bucket that is not public?

Yes. Set `ACL: 'private'` under `s3Options.params` and the provider returns signed URLs that expire after `signedUrlExpires` seconds. Cache API responses for less time than that, and make sure the signing endpoint is reachable from the visitor's browser.

##### Why do uploads work but the Media Library thumbnails stay blank?

Strapi's Content Security Policy only allows images from its own origin and a short list of hosts. Add the host of your `baseUrl` to `img-src` and `media-src` in `config/middlewares.js`, and restart.

##### Will switching to S3 move my existing media?

No. Existing files keep their `/uploads/...` URLs and stay on the server's disk. Copy them to the bucket and update the stored URLs, or leave them where they are and let only new uploads use the bucket.

##### Does Strapi delete the files from the bucket when I delete them in the admin?

Yes, through the provider's `delete` call, which removes the original and its resized variants. The access key needs permission to delete objects, and a key limited to writing will leave the files behind.

An S3 storage plan on RE:NODE comes with an S3-compatible endpoint, an access key and secret key generated for the server, and a first bucket already created, using path-style addressing and any region name. HTTPS goes through the plan's proxy slot.

[See S3 storage plans](https://www.renode.space/object-storage#s3-storage)

---

### Django Celery with Valkey: broker, beat and memory limits

Published 2026-10-18, updated 2026-10-18 by RE:NODE Team. https://www.renode.space/blog/django-celery-and-valkey

Celery in Django is three pieces of wiring and four settings you should not leave at their defaults. The wiring: a `celery.py` next to `settings.py`, an import in `__init__.py`, and `CELERY_`-prefixed settings read from Django's own settings module. The settings: `acks_late`, the prefetch multiplier, the visibility timeout and a memory ceiling per child process. Valkey is the broker, and because it speaks the Redis protocol you point Celery at it with an ordinary `redis://` URL.

The part that goes wrong on a small plan is not the configuration but the arithmetic. A Celery worker is a copy of your Django project, repeated once for the parent and once per child process, and it sits next to gunicorn, which is the same project repeated again. This post sets up the broker and the beat schedule, then spends most of its length on how many processes fit in the memory you bought and what to do when they do not. It assumes the project is already deployed as described in [deploy Django to production with gunicorn](https://www.renode.space/blog/deploy-django-to-production), and it does not repeat the library-by-library comparison in [job queues with Valkey](https://www.renode.space/blog/job-queues-with-valkey).

#### What you are building

Three kinds of process share one Valkey. The web process (gunicorn) enqueues a task and returns. One or more workers take tasks off the queue and run them. One beat process, and exactly one, adds periodic tasks to the queue on a clock. Valkey holds the messages in between.

```diagram
title: Django, Celery and Valkey on a small plan
web = gunicorn | Django web process
valkey = Valkey | broker, results
worker = Celery worker | prefork children
beat = Celery beat | one process only
db = PostgreSQL | application data
web -> valkey : task.delay()
beat -> valkey : due tasks
valkey -> worker : message
worker -> db : ORM writes
web -> db : ORM reads
```

Two things in that picture shape everything below. Beat never runs tasks, it only sends them, so it is tiny. And the worker needs its own database connections, because it imports your models and talks to the database exactly as the web process does. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) has the arithmetic for how those add up.

#### Installing Celery for Django

Install Celery with the Redis transport extra, which pulls in the client library that also talks to Valkey:

```bash
$ pip install "celery[redis]"
```

Pin the version in `requirements.txt` like everything else. Celery's configuration key names changed in the 4.x series and several defaults moved between 5.x releases, so a range that floats across a minor version is how a restart at three in the morning changes behaviour. [Python requirements and virtualenvs](https://www.renode.space/blog/python-requirements-and-virtualenvs) covers lock files.

The Django integration is three files. First the Celery application, in the same package as `settings.py`:

myproject/celery.py:

```python
import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings.production")

app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
```

Then make sure it is imported when Django starts, so that `@shared_task` decorators bind to this application:

myproject/__init__.py:

```python
from .celery import app as celery_app

__all__ = ("celery_app",)
```

`namespace="CELERY"` means every Celery setting in `settings.py` carries a `CELERY_` prefix and is upper case: the Celery setting `task_acks_late` becomes `CELERY_TASK_ACKS_LATE`. `autodiscover_tasks()` imports a `tasks.py` module from every app in `INSTALLED_APPS`. A task that lives anywhere else is never registered, and the worker will log `Received unregistered task` when a message for it arrives.

Tasks go in `tasks.py` inside the app, using `shared_task` so the app does not import the Celery instance:

shop/tasks.py:

```python
from celery import shared_task

from .models import Order


@shared_task(
    autoretry_for=(ConnectionError, TimeoutError),
    retry_backoff=True,
    retry_backoff_max=600,
    retry_jitter=True,
    max_retries=5,
)
def send_receipt(order_id):
    order = Order.objects.get(pk=order_id)
    order.send_receipt_email()
```

Pass the primary key, not the model instance. A task carrying a whole object works with whatever it looked like when the message was written, which may be an hour ago if the queue is backed up.

#### The broker settings

The connection and the behaviour settings go in the Django settings module, read from the environment so the password is not in the repository:

settings/production.py:

```python
import os

CELERY_BROKER_URL = os.environ["VALKEY_URL"]          # redis://:password@host:port/0
CELERY_RESULT_BACKEND = os.environ["VALKEY_URL"].replace("/0", "/1")
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True

CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 240
CELERY_RESULT_EXPIRES = 3600
CELERY_TASK_IGNORE_RESULT = True

CELERY_BROKER_TRANSPORT_OPTIONS = {
    "visibility_timeout": 3600,
    "global_keyprefix": "shop:",
}

CELERY_TIMEZONE = "UTC"
```

Each one is there for a reason.

| Setting | Value | Why |
| --- | --- | --- |
| `CELERY_BROKER_URL` | `redis://:pw@host:port/0` | The empty username is deliberate: Valkey's default user takes only a password |
| `CELERY_TASK_ACKS_LATE` | `True` | Acknowledge after the task finishes, not when it starts |
| `CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | A child holds one reserved message, not four |
| `CELERY_TASK_TIME_LIMIT` | seconds | Hard kill for a task that hangs |
| `CELERY_RESULT_EXPIRES` | `3600` | Results expire instead of accumulating as keys |
| `CELERY_TASK_IGNORE_RESULT` | `True` | Store nothing unless a task asks to |

The URL comes from your Valkey plan's connection details: a host, a port and a generated password, none of which are the defaults of a local install. If the password contains characters such as `@`, `/` or `#`, percent-encode them in the URL (`@` becomes `%40`) or the client will split the string in the wrong place and report a confusing host error. Python's `urllib.parse.quote` does it.

`CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP` matters on restart. Without it, a worker that starts a few seconds before the broker is reachable exits, and recent Celery 5.x releases print a deprecation warning telling you to set it. A hosting panel restarts everything at once after a deploy, so the order of arrival is not under your control.

The database number after the slash is a logical database inside the same Valkey. Putting results in database `1` keeps them out of the queue's keyspace, so you can inspect or flush one without touching the other. If you never read task return values, `CELERY_TASK_IGNORE_RESULT = True` and no result backend at all is the cheapest option and removes a whole class of leftover keys.

> [!WARNING]
> Use a unique `global_keyprefix` if anything else shares this Valkey database. Celery uses plain key names such as `celery` for the default queue, and another application or a flush from a different tool will happily collide with them.

#### Acks, prefetch and the visibility timeout

The Redis transport has no native acknowledgement, so Celery builds one. A message taken by a worker moves into a hidden structure called `unacked`. If the worker acknowledges within the visibility timeout the message is deleted; if it does not, the message is put back on the queue and another worker runs it.

That gives three rules.

1. **The visibility timeout must be longer than your longest task, including its retries' countdowns.** The default is one hour. A task that runs for ninety minutes under the default is delivered a second time while the first is still running. This is the most common cause of "my task ran twice" on a Redis-protocol broker. Notice that `ETA` and `countdown` tasks are held by the worker, not by Valkey, until due, so a task scheduled for tomorrow with a one-hour timeout is redelivered every hour. Short timeouts and long countdowns do not mix; schedule far-future work with beat instead.
2. **With `acks_late`, a task may run more than once.** If a worker is killed mid-task, the message returns and the task starts again from the top. Make tasks idempotent: a unique constraint on an event id, or a status column checked first. `CELERY_TASK_REJECT_ON_WORKER_LOST` extends this to a child killed by the kernel for memory, which is the case that matters on a small plan.
3. **Prefetch 1 trades throughput for fairness.** The default multiplier of 4 means each child reserves four messages in advance. If one of them is a ten-minute report, the three behind it wait, even if other children are idle. For tasks of mixed length, `1` is right; for thousands of tiny identical tasks, the default is faster.

`acks_late` without `reject_on_worker_lost` has one more trap: if the child is killed by the out-of-memory killer, the message is acknowledged as failed in some versions rather than retried. Setting both is what makes a memory kill behave like a retry.

Enqueue after the transaction commits, not before. Inside a request wrapped in a database transaction, a task enqueued with `delay()` can reach a worker before the row it refers to exists, and fail with `DoesNotExist`:

```python
from django.db import transaction

def create_order(request):
    order = Order.objects.create(...)
    transaction.on_commit(lambda: send_receipt.delay(order.pk))
```

`on_commit` runs the callback once the outermost transaction commits, and discards it on rollback, which is what you want for a task that describes work caused by the data.

#### Beat: the periodic schedule

Beat reads a schedule and sends tasks at the due time. It is its own process, and there must be exactly one running per schedule. Two beats means every periodic task is sent twice. This is the single most important fact about scaling Celery: you can add workers freely, but never add a second beat.

The simplest schedule lives in settings:

settings/production.py:

```python
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    "clear-expired-sessions": {
        "task": "accounts.tasks.clear_sessions",
        "schedule": crontab(hour=3, minute=30),
    },
    "daily-digest": {
        "task": "notifications.tasks.send_digest",
        "schedule": crontab(hour=7, minute=0, day_of_week="mon-fri"),
        "options": {"expires": 3600},
    },
    "sync-stock": {
        "task": "shop.tasks.sync_stock",
        "schedule": 300.0,
        "args": (),
    },
}
```

A bare number is an interval in seconds, measured from when beat starts. A `crontab` fires at wall-clock times and uses `CELERY_TIMEZONE`; set it explicitly, because the default is UTC and a digest "at 07:00" is otherwise not at 07:00 where you live. If cron syntax is hazy, [cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) is the reference, with the caveat that Celery's `crontab()` takes keyword fields rather than a five-part string.

The `expires` option is worth copying. If workers are down for an hour, a schedule that fires every five minutes leaves twelve identical `sync_stock` messages waiting, and they all run in a burst when the workers return. With `expires` the stale ones are discarded unread.

Start beat in its own process:

```bash
$ celery -A myproject beat --loglevel=INFO -s /tmp/celerybeat-schedule
```

`-s` sets where beat keeps `celerybeat-schedule`, a small file recording when each entry last ran. The default writes it into the working directory. Beat is happy to lose it on restart; it then works out what is due from the clock, which for crontab entries is correct and for interval entries means "start counting again". A task that must run exactly once per day should be a crontab entry, not a 86400-second interval.

You will see advice to run beat inside the worker with `celery worker -B`. It saves a process, and it is fine for a single worker on a small server, but it is the reason people end up with two beats the day they add a second worker. Use it knowingly, with `--concurrency` set explicitly and the worker count fixed at one.

For schedules that staff should edit without a deploy, `django-celery-beat` stores them in the database and adds admin pages:

```bash
$ pip install django-celery-beat
$ celery -A myproject beat --scheduler django_celery_beat.schedulers:DatabaseScheduler
```

Add `"django_celery_beat"` to `INSTALLED_APPS` and run `python manage.py migrate`. The trade is one more set of tables and a schedule that no longer lives in version control. For a handful of fixed jobs, keep the dictionary above.

#### Memory and concurrency on a small plan

This is where Celery on a small server succeeds or is killed. Celery's default pool, `prefork`, starts one parent and then `--concurrency` child processes. The default concurrency is the number of CPU cores the process sees, which on a shared host can be the cores of the whole machine rather than your share. Set it yourself.

Every child imports your Django project, so each costs about what one gunicorn worker costs: commonly 100-200 MB for a modest project, more with heavy dependencies. [How much RAM a Python app needs](https://www.renode.space/blog/how-much-ram-does-a-python-app-need) explains how to measure it. The parent adds a similar amount. A worker started with `--concurrency 2` is therefore three processes, and with gunicorn beside it the project is loaded five or six times.

| Plan RAM | Layout | Notes |
| --- | --- | --- |
| 1 GB | Web only, worker elsewhere | One gunicorn worker alone is around 200 MB |
| 2 GB | gunicorn 1 + Celery `-c 1` | Tight; measure before trusting it |
| 4 GB | gunicorn 2 + Celery `-c 2` | A sensible small production layout |
| 6-8 GB | gunicorn 3 + Celery `-c 3-4` | Past this, tasks are bound by the database |

Those are starting points built from the per-process figures above, not measurements of your code. Run the worker under load and read the resident size of each process with `ps -o pid,rss,cmd -C celery` before you commit to a layout. On RE:NODE the console graphs memory against the plan's limit while you do it, and the limit is a kill switch: at the memory limit the kernel stops the container and it restarts clean instead of swapping. For a Celery worker that is the death of every child at once, which is exactly why `acks_late` and `reject_on_worker_lost` above are not optional.

Four measures keep a worker inside its budget.

**Choose the pool by workload.** Prefork is right for CPU-bound or library-heavy tasks and gives true isolation. For tasks that mostly wait on a network call, the thread pool uses one copy of the project for many concurrent tasks:

```bash
$ celery -A myproject worker --pool=threads --concurrency=8 --loglevel=INFO
```

Threads share the Django process, so a leaking task leaks for everyone, and the usual thread-safety caveats apply to your own code. For an email-and-webhooks workload the saving is large: eight concurrent tasks for roughly the memory of one. There is also `--pool=solo`, a single task at a time in the main process, which is the smallest possible footprint and a reasonable choice on a 1 GB plan with occasional jobs.

**Recycle children.** Python processes rarely return memory to the operating system, so a child that once handled a large export stays large. Two settings bound it:

settings/production.py:

```python
CELERY_WORKER_MAX_TASKS_PER_CHILD = 200
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300_000   # kilobytes, about 300 MB
```

After either limit is reached the child finishes its current task and is replaced by a fresh one. The memory limit is in kilobytes, which catches people out. Recycling is not a fix for a leak, but it turns a leak from an outage into a note in the log.

**Cut the idle chatter.** On a small plan, three flags remove traffic and processes you do not need when there is a single worker:

```bash
$ celery -A myproject worker --concurrency=2 -Ofair \
    --without-gossip --without-mingle --without-heartbeat
```

`-Ofair` makes the parent give work only to children that are free, which matters with long tasks and prefetch above 1. `--without-gossip`, `--without-mingle` and `--without-heartbeat` stop the worker exchanging cluster state with other workers, useful when there are no others. Without the heartbeat, `celery status` and `inspect` commands from other nodes are less informative, so leave it on if you run several workers and rely on those.

**Split queues, then limit them.** A report that takes five minutes and a password reset that takes one second should not share children. Route them apart:

settings/production.py:

```python
CELERY_TASK_DEFAULT_QUEUE = "default"
CELERY_TASK_ROUTES = {
    "reports.tasks.*": {"queue": "slow"},
}
```

```bash
$ celery -A myproject worker -Q default --concurrency=2 -n fast@%h
$ celery -A myproject worker -Q slow    --concurrency=1 -n slow@%h
```

The slow queue gets one child, so a heavy export can use at most one core's share and one process's memory, and everything urgent keeps flowing. The cost is another parent process. On a very small plan it is cheaper to keep one queue and one worker and accept that urgent tasks wait.

CPU is a separate limit from memory. On RE:NODE the CPU figure is a hard throttle to the share you bought, so a `--concurrency 4` worker on half a core does not run four tasks at the speed of four cores; it runs them slowly, all at once. Concurrency above the CPU share helps only for tasks that spend their time waiting on something else.

#### Where the processes run

A web process, a worker and a beat are three long-running commands. An app server on RE:NODE has one start command, so the straightforward layout is separate Python servers from the same repository, each with its own start command, all pointing at the same Valkey and database through environment variables. The web server runs gunicorn, the worker server runs `celery -A myproject worker`, and the beat process is added to the worker's command or given its own small server. That costs more than a single plan and removes the question of which process gets killed when memory runs out.

If you would rather keep everything in one server, a shell start command that launches the worker in the background and gunicorn in the foreground works, but be clear about what you are giving up: if the worker dies, nothing restarts it while gunicorn stays up, and the queue stops draining without any visible failure. Look at the console after every deploy, and prefer separate servers for anything that matters.

The [Valkey plans](https://www.renode.space/database-hosting#valkey) start at 256 MB for $2 a month, and the Python plans start at $4. A Valkey server is reached on its own host and port with a generated password, so web and worker servers connect to it the same way from wherever they run. There is also a bundle of a Python plan, a PostgreSQL plan and a 256 MB Valkey plan, bought together at a discount on the panel shop's Bundles tab, which is the shape of this whole post: a web app, a database and a broker.

Git deploys apply to each server separately. Pushing to the repository restarts every server that has deploy-on-push switched on, so workers pick up new code on the same push as the web process. A running Celery worker keeps the old code in memory until it restarts, so this matters. A restart that arrives mid-task is a normal stop, and `acks_late` means the interrupted task is delivered again. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers why the stop signal should let a task finish.

#### Valkey settings a Celery broker needs

Three server-side settings decide whether messages survive. [Job queues with Valkey](https://www.renode.space/blog/job-queues-with-valkey) goes through them for every library; the short version for Celery:

- **Eviction.** Check `maxmemory-policy` on your instance. Anything but `noeviction` can delete the `celery` queue list or the `unacked` structure when memory is full. [Valkey memory and eviction policies](https://www.renode.space/blog/valkey-memory-and-eviction-policies) explains the policies.
- **Persistence.** A Valkey plan on RE:NODE saves to disk with AOF and snapshots, so queued tasks survive a restart. [AOF and RDB](https://www.renode.space/blog/valkey-persistence-aof-and-rdb) explains what each costs.
- **Size.** A message is a few hundred bytes to a few kilobytes. A 256 MB instance holds a very large backlog of small messages; the way it fills is a result backend nobody expires, or large arguments.

Inspect what Celery has created with `valkey-cli`:

```bash
$ valkey-cli -h HOST -p PORT --askpass
> KEYS *
> LLEN celery
> HLEN unacked
> INFO memory
```

`LLEN celery` is the number of waiting messages on the default queue, and `HLEN unacked` is the number taken but not yet acknowledged. `KEYS *` is fine on a small instance and a bad habit on a large one; use `SCAN` there. [Getting started with Valkey](https://www.renode.space/blog/valkey-getting-started) covers connecting.

Run `celery -A myproject inspect active` to see what each worker is executing right now, `celery -A myproject inspect stats` for pool and memory figures, and `celery -A myproject purge` to throw away everything waiting, which asks for confirmation and is a decision to make deliberately.

#### Troubleshooting

**The worker starts and then does nothing.** The broker URL is wrong in a way that does not error immediately, or the tasks are on a different queue than the worker listens to. Check `celery -A myproject inspect registered`, then `LLEN` on the queue name.

**`Received unregistered task of type`.** The worker did not import the module that defines the task. Check `autodiscover_tasks()`, that the app is in `INSTALLED_APPS`, and that the file is named `tasks.py`.

**`kombu.exceptions.OperationalError` or `Connection refused`.** The host, port or password is wrong, or the web process has no network path to Valkey. Test the same URL with `valkey-cli` from the same server first.

**A task runs twice.** The visibility timeout is shorter than the task, or a child was killed before acknowledging. Raise the timeout, set `acks_late` with `reject_on_worker_lost`, and make the task idempotent.

**Periodic tasks run twice at the same moment.** Two beat processes. Check for a second server, or for `-B` on more than one worker.

**Periodic tasks never run.** Beat is not running, or it is running with a different settings module than the one that holds `CELERY_BEAT_SCHEDULE`. Beat logs `Sending due task` for each one when it works.

**The server restarts every few hours.** A memory kill. Compare the sum of the web workers, the parent and the children against the plan with `ps`, then apply the measures above. A weekly scheduled restart from the Schedules tab is a blunt but effective backstop; [restart schedules that help](https://www.renode.space/blog/restart-schedules-that-help) covers the timing.

**`DatabaseError: connection already closed` inside a task.** A long-lived worker kept a connection that the database dropped. Call `django.db.close_old_connections()` at the start of long tasks, or keep `CONN_MAX_AGE` modest in the settings the worker uses.

**Tasks pile up while workers sit idle.** Often the prefetch multiplier combined with one very long task. Set it to `1` and use `-Ofair`.

#### FAQ

##### Do I need Celery for a Django project?

Often not. A management command run on a schedule covers nightly cleanups and digests with no broker, and a database-backed queue covers modest volumes. Celery is worth it when tasks are frequent, need retries with backoff, and must survive a restart. [Background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) compares the options.

##### Can Celery use Valkey as both broker and result backend?

Yes. Use the same server with a different database number, as in the settings above, or set `CELERY_TASK_IGNORE_RESULT = True` and skip the backend. The Django-specific alternative is `django-celery-results`, which stores results in your database with `CELERY_RESULT_BACKEND = "django-db"`.

##### How many Celery workers should I run?

Enough children to keep the backlog near zero at peak, and no more than the memory allows. For network-bound tasks, a thread pool of 8 to 20 on one process is cheaper than eight prefork children. For CPU-bound tasks, one child per core you actually have. The right answer on a small plan is nearly always fewer than the default.

##### Why does the number of keys in Valkey keep growing?

Stored results with no expiry, or `unacked` entries that never clear because tasks are never acknowledged. Set `CELERY_RESULT_EXPIRES`, ignore results you do not read, and look at `HLEN unacked` when a worker has been killed repeatedly.

##### Is it safe to run beat on every server?

No. Exactly one beat process per schedule, or every periodic task is sent once per beat. Put it on one server, or embed it with `-B` in a single worker that you never scale out.

##### What happens to queued tasks when Valkey restarts?

With persistence on, the queue is reloaded from disk and workers reconnect when `CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP` is set. Without persistence, every waiting task is gone. Tasks that were in `unacked` return to the queue after the visibility timeout.

A Valkey server for your Celery broker, from 256 MB: password-protected, credentials generated for it, saved to disk with AOF and snapshots so queued tasks survive a restart. Run the web process and the worker as separate Python servers and point both at it.

[See Valkey plans](https://www.renode.space/database-hosting#valkey)

---

### Quarkus and Micronaut on a Java server: fast-jar and heap

Published 2026-10-18, updated 2026-10-18 by RE:NODE Team. https://www.renode.space/blog/quarkus-and-micronaut-on-a-java-server

Quarkus and Micronaut both run on a plain Java server as a normal jar, and on a small plan they are the easier frameworks to fit: they do their dependency injection work at build time, so they start in a second or two and carry a smaller heap than a comparable Spring Boot service. The three things that catch people are the artefact layout (Quarkus does not produce one file by default), the memory limit (the JVM will happily ask for more than you pay for), and the health endpoint paths, which are not the ones Spring uses. This post covers all three, plus the question everyone asks first: should you build a native executable or just run on the JVM?

It is deliberately the companion to [deploying a Spring Boot app to production](https://www.renode.space/blog/deploy-spring-boot-app-to-production), which covers the general path for a fat jar - Java versions, externalised configuration, HikariCP, proxy headers. Here we stay with what differs for Quarkus and Micronaut, and repeat only what you need to follow along.

#### What differs from a Spring Boot fat jar

A Spring Boot build gives you one executable file. Quarkus and Micronaut give you the same thing in different shapes, and the shape decides what you upload and what the start command is.

| Framework | Default build output | Run with |
| --- | --- | --- |
| Quarkus | `target/quarkus-app/` directory | `java -jar target/quarkus-app/quarkus-run.jar` |
| Quarkus (uber-jar) | `target/*-runner.jar` | `java -jar target/*-runner.jar` |
| Micronaut (Gradle) | `build/libs/*-all.jar` | `java -jar build/libs/app-0.1-all.jar` |
| Micronaut (Maven) | `target/*.jar` | `java -jar target/app-0.1.jar` |

The Micronaut names depend on your project name and version, and on the plugins you applied, so treat the file names above as examples and look at the directory after a build. For Micronaut with Gradle, the `-all.jar` comes from the Shadow plugin and the `shadowJar` task; with Maven, the generated project uses the Shade or Micronaut Maven plugin to produce the runnable archive.

The common ground is this: both frameworks listen on a port you choose, read configuration from files and environment variables, and need a Java runtime of at least the version they were compiled for. RE:NODE's Java app line runs any jar on Java 17, 21 or 25, which covers Quarkus, Micronaut, Spring Boot and Ktor alike. Current Quarkus 3 releases and Micronaut 4 releases require Java 17 or newer; check the version of your own framework in its release notes before choosing, and compile with a `--release` flag matching the oldest Java you intend to run on. A jar built for 17 runs on 21 and 25, never the other way round.

#### Quarkus packaging: fast-jar, uber-jar and the rest

The Quarkus default is called **fast-jar**. It is not one file. The build writes a directory:

```text
target/quarkus-app/
  quarkus-run.jar
  lib/
    boot/
    main/
  app/
  quarkus/
```

`quarkus-run.jar` is a tiny launcher. The dependencies sit unpacked-as-jars in `lib/`, your own classes in `app/`, and generated build-time data in `quarkus/`. The layout exists for one reason: start-up is faster when the runtime does not have to open and scan one enormous archive. It also makes deploys cheaper, because most of the directory does not change between releases - only `app/` and `quarkus/` do, so an incremental upload (or a layered container image) moves a few hundred kilobytes rather than fifty megabytes.

The consequence for hosting is simple but easy to get wrong: **you must upload the whole `quarkus-app` directory, not just `quarkus-run.jar`.** The launcher alone fails with a class-loading error because `lib/` is not beside it. Archive the directory in CI, upload the archive, and unpack it in place. The panel's file manager unpacks archives where they sit, which is exactly what this needs.

```bash
$ ./mvnw -B clean package
$ tar -czf quarkus-app.tar.gz -C target quarkus-app
$ ls -lh quarkus-app.tar.gz
```

The packaging type is controlled by one property, set in `application.properties` or on the command line with `-D`:

| `quarkus.package.jar.type` | Result |
| --- | --- |
| `fast-jar` | The default directory layout above |
| `uber-jar` | One runnable `*-runner.jar` with everything inside |
| `legacy-jar` | The older single jar plus `lib/`, kept for compatibility |
| `mutable-jar` | A fast-jar that can be re-augmented at start, for dev tooling |

On older Quarkus releases (before 3.9) the property was `quarkus.package.type`, and the values were the same words. If the build ignores your setting, you are reading documentation for the wrong major version.

application.properties:

```properties
quarkus.package.jar.type=uber-jar
```

When is an uber-jar the right call? When your deployment path really wants one file - dragging a single jar into a file manager, or a start command you do not want to touch. The price is slower start and a larger single archive that changes on every release. If you can handle a tarball, keep fast-jar. If you cannot, the uber-jar is fine and many small services run that way without trouble.

Micronaut has no equivalent choice to make. You get a runnable jar from the build, and the `-all.jar` suffix (Gradle) tells you which file to upload. Its start-up speed comes from ahead-of-time processing in the annotation processor rather than from the archive layout.

#### Native image or JVM: which one to run

Both frameworks can compile your application to a native executable with GraalVM. The numbers that make people want it are real: start-up in tens of milliseconds and a much smaller resident memory footprint. The costs are real as well, and for a small hosted service they usually outweigh the gain.

| | JVM mode | Native executable |
| --- | --- | --- |
| Start-up | About a second or two | Tens of milliseconds |
| Memory at idle | Heap plus JVM overhead | Noticeably lower |
| Peak throughput | Higher once warmed up | Often lower, no JIT |
| Build time | Seconds to a minute | Minutes |
| Build memory | Modest | Several gigabytes |
| Reflection, proxies | Just work | Must be known at build time |
| Debugging, profiling | Full JVM tooling | Limited |
| Portability | Any machine with Java | One OS and CPU architecture |

The commands, for reference:

```bash
# Quarkus: build a native executable (needs GraalVM or Mandrel installed)
$ ./mvnw package -Dnative

# Quarkus: build it inside a container, so you need no local GraalVM
$ ./mvnw package -Dnative -Dquarkus.native.container-build=true

# Micronaut, Gradle
$ ./gradlew nativeCompile

# Micronaut, Maven
$ ./mvnw package -Dpackaging=native-image
```

Quarkus writes a `*-runner` binary into `target/`. It only runs on the operating system and CPU architecture it was built for, which is why the container build option exists: it gives you a Linux binary even if you develop on something else.

**The honest recommendation for a hosted app server is JVM mode.** Three reasons. First, the Java line on RE:NODE is built around running a jar on Java 17, 21 or 25; a native executable is a different kind of file and the Java line is not documented as running one, so confirm with support before you plan around it. Second, a native build wants several gigabytes of memory for minutes, which does not belong on the production plan you are trying to keep small - it belongs in CI. Third, JVM mode with sensible heap flags is already small enough: a Quarkus or Micronaut service with a modest dependency tree fits in a 1 GB plan, which is the cheapest tier. Native is worth the trouble when you scale to zero and start often (functions, jobs), when you run very many tiny instances, or when memory is the binding constraint on a larger deployment. For one service on one plan, it is a cost without a payoff.

If you do go native, remember that reflection, resource loading and dynamic proxies must be declared at build time. Libraries that rely on them need configuration or an extension. Both frameworks ship extensions for the common ones, and a missing registration shows up at runtime as a class-not-found or a missing resource that worked perfectly in JVM mode - so run your integration tests against the native build, not just the JVM one. Quarkus has `@QuarkusIntegrationTest` for exactly this.

#### Ports and binding

The listening port is the first thing that breaks in any framework, and both of these default to `8080`. Your server has an allocated port, and your application must listen on it, on all interfaces. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers where to set them; the keys are below.

Quarkus - application.properties:

```properties
quarkus.http.host=0.0.0.0
quarkus.http.port=${SERVER_PORT:8080}
```

Micronaut - application.yml:

```yaml
micronaut:
  server:
    port: ${SERVER_PORT:8080}
```

`${SERVER_PORT:8080}` means "use the environment variable if it is set, otherwise 8080", which works on your laptop and in production without edits. Check the Startup and Network tabs for the variable your server actually provides before relying on the name `SERVER_PORT`; a Pterodactyl-derived panel commonly exposes the allocation under that name, but verify it for your server.

Both frameworks also map environment variables to configuration keys with no code at all: upper case the key and turn dots and dashes into underscores.

| Key | Environment variable |
| --- | --- |
| `quarkus.http.port` | `QUARKUS_HTTP_PORT` |
| `quarkus.datasource.jdbc.url` | `QUARKUS_DATASOURCE_JDBC_URL` |
| `micronaut.server.port` | `MICRONAUT_SERVER_PORT` |
| `datasources.default.url` | `DATASOURCES_DEFAULT_URL` |

Quarkus's default bind address depends on the mode: it is the loopback address in dev mode and all interfaces in a normal production run. Setting `quarkus.http.host=0.0.0.0` explicitly costs nothing and removes the doubt. A service bound to `127.0.0.1` inside its container is reachable by nobody, and the proxy reports a bad gateway while the console insists the app is running.

Profiles work the same way in both. In Quarkus, a `%prod.` prefix scopes a key to the production profile, which is what `java -jar` runs unless told otherwise; `%dev.` and `%test.` are the others. In Micronaut, environments are named with `MICRONAUT_ENVIRONMENTS=prod`, which loads `application-prod.yml` on top of `application.yml`.

application.properties:

```properties
quarkus.http.port=8080
%prod.quarkus.http.port=${SERVER_PORT:8080}
%prod.quarkus.log.level=INFO
%dev.quarkus.log.level=DEBUG
```

#### Heap flags for a small memory limit

This is the part that matters most. A container has a hard memory limit, and the JVM decides for itself how big a heap to ask for. The detailed treatment is in the Spring Boot post; the working version is here.

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. That is the better behaviour for a service, but it changes how you read a failure. There are two quite different ones:

- **`java.lang.OutOfMemoryError: Java heap space`** in the console. The JVM noticed it ran out of heap and said so. The heap is too small for the data, or something leaks.
- **A restart with no Java error at all.** The whole process went over the container limit and was stopped from outside. The heap was too large for the plan, or memory outside the heap grew.

The second is the common one, because the JVM's footprint is more than its heap. It also holds metaspace for class metadata, thread stacks, the JIT code cache, direct buffers and garbage collector structures. Quarkus adds Netty and Vert.x, which allocate direct (off-heap) buffers for network I/O; that is part of why a Quarkus service's resident size is bigger than its `-Xmx`. Micronaut on Netty does the same.

Set the heap as a share of the container limit, so it follows you when you change plan:

```bash
$ java -XX:MaxRAMPercentage=60.0 -XX:InitialRAMPercentage=25.0 \
    -XX:+ExitOnOutOfMemoryError \
    -jar target/quarkus-app/quarkus-run.jar
```

If you cannot edit the start command, put the flags in the environment instead; every JVM that starts reads `JAVA_TOOL_OPTIONS`, and prints `Picked up JAVA_TOOL_OPTIONS: ...` at the top of the console so you can see it took effect:

environment:

```env
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60.0 -XX:+ExitOnOutOfMemoryError
```

Do not confuse that with `JAVA_OPTS` and `JAVA_OPTS_APPEND`. Those are read by the launch scripts inside the Red Hat and Quarkus container images, not by the `java` binary itself, so on a plain `java -jar` start they do nothing.

Starting points by plan size, which are guesses to be replaced with a graph:

| Plan memory | Heap setting | Why |
| --- | --- | --- |
| 1 GB | `-Xmx256m` to `-Xmx384m` | Roughly half goes to non-heap and the OS |
| 2 GB | `MaxRAMPercentage=60` | About 1.2 GB heap, the rest for overhead |
| 4 GB | `MaxRAMPercentage=65` | Overhead is a smaller share |
| 6 GB and up | `MaxRAMPercentage=70` | Keep room for buffers and spikes |

The sensible way to find your own number: set a deliberately generous heap on a plan with spare memory, run real traffic for a day, and watch the memory graph in the console against the limit. A small REST service on either framework commonly needs a heap of 128 to 256 MB; a service that caches or handles large payloads needs more. Set the heap to what you observed plus headroom, not to a round percentage of the plan.

A few flags earn their place on small plans:

| Flag | Effect |
| --- | --- |
| `-XX:+ExitOnOutOfMemoryError` | Exit on heap OOM so the platform restarts a clean process |
| `-XX:MaxMetaspaceSize=128m` | Cap class metadata growth |
| `-XX:MaxDirectMemorySize=128m` | Cap the direct buffers Netty uses |
| `-XX:+UseSerialGC` | Lowest overhead collector for 1 CPU or a small heap |
| `-Xss512k` | Smaller thread stacks if you run many threads |

`-XX:+ExitOnOutOfMemoryError` is the one most people skip. A JVM that has thrown a heap OOM in one thread is often alive but wrecked: it serves some requests and fails others. Exiting hands you a clean process. Mind that restarts are watched. The crash watcher polls every two minutes for uptime that went backwards or a server that went offline; restarts you asked for are not counted, three restarts in an hour put a warning on the server page and open an automatic ticket, and six mean suspension. A service stuck in an out-of-memory loop is precisely what that looks for, so fix the sizing before leaving it unattended.

CPU matters as well. It is a hard throttle to the share you bought, and 100% is slow, never grounds for suspension. The JVM sizes its thread pools from the CPU count it sees, which can round a half-core share up to one. If start-up is slow or latency comes in bursts on the smallest tier, `-XX:ActiveProcessorCount=1` is worth trying. The general lesson of [CPU vs RAM for game servers](https://www.renode.space/blog/cpu-vs-ram-for-game-servers) applies unchanged: find out which resource is binding before buying more of the other.

##### Threads, not just heap

Both frameworks run on Netty, and both can run blocking code on a worker pool. Quarkus sets that pool's size by default from the CPU count it detects, and the key to cap it is `quarkus.thread-pool.max-threads`. Each thread costs a stack and some native memory, so a worker pool of 200 on a service that never has more than 10 requests in flight just inflates the footprint. On Micronaut, the equivalent lives under `micronaut.executors`, for example `micronaut.executors.io.type: fixed` with `micronaut.executors.io.nThreads: 16`. Set it to what the service really uses.

#### Database connections

The connection details come from whichever database you attach. App servers have database slots created in the panel, with a generated host, user and password; a standalone [database hosting](https://www.renode.space/database-hosting) plan gives PostgreSQL or MySQL 8.4 LTS with credentials generated per server. Keep the values in environment variables, never inside the jar.

environment - Quarkus:

```env
QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://db-host:5432/shop
QUARKUS_DATASOURCE_USERNAME=shop_app
QUARKUS_DATASOURCE_PASSWORD=change-me
```

application.properties:

```properties
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.max-size=8
quarkus.datasource.jdbc.min-size=2
```

Quarkus uses Agroal as its pool, and `quarkus.datasource.jdbc.max-size` is the ceiling. For Micronaut the datasource is configured under `datasources.default`, with HikariCP as the default pool:

Micronaut - application.yml:

```yaml
datasources:
  default:
    url: ${DATASOURCES_DEFAULT_URL}
    username: ${DATASOURCES_DEFAULT_USERNAME}
    password: ${DATASOURCES_DEFAULT_PASSWORD}
    maximum-pool-size: 8
```

Whatever the pool, the arithmetic is the same: every connection is held by the database, so two instances with a pool of eight each, plus a migration job, add up against the database's own limit. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) goes through it. A pool of six to eight is plenty for a service on one or two cores.

#### Health endpoints in Quarkus and Micronaut

Neither framework uses Spring's `/actuator/health`. Both give you a health endpoint only after you add a dependency, and the paths differ.

##### Quarkus: SmallRye Health

Add the extension:

```bash
$ ./mvnw quarkus:add-extension -Dextensions="smallrye-health"
```

That gives you four endpoints under the `/q` prefix:

| Path | Question it answers |
| --- | --- |
| `/q/health` | Everything combined: live, ready and started |
| `/q/health/live` | Is the process in a state where it can continue? |
| `/q/health/ready` | Should this instance receive traffic? |
| `/q/health/started` | Has start-up finished? |

A response is `{"status":"UP","checks":[...]}` with HTTP 200, or `DOWN` with 503. Extensions contribute checks on their own - the datasource extension adds a database readiness check, for instance, so a missing database turns `/q/health/ready` red without you writing anything. Your own checks are CDI beans:

```java
@ApplicationScoped
@Readiness
public class CacheWarmCheck implements HealthCheck {
    @Override
    public HealthCheckResponse call() {
        return HealthCheckResponse.up("cache-warm");
    }
}
```

The `/q` prefix is the Quarkus non-application root and is configurable with `quarkus.http.non-application-root-path`. If you move it, update whatever monitors the service.

##### Micronaut: the management module

Add `micronaut-management` to the build. The endpoints are enabled by configuration:

Micronaut - application.yml:

```yaml
endpoints:
  health:
    enabled: true
    details-visible: NEVER
```

The base path is `/health`, with `/health/liveness` and `/health/readiness` beneath it. `details-visible` takes `NEVER`, `AUTHENTICATED` or `ANONYMOUS` and decides who sees the per-indicator breakdown. Leave it at `NEVER` on a public address: details can reveal hostnames and versions. To keep management endpoints off the public port entirely, `endpoints.all.port` moves them to a separate one.

##### What a good check does

Liveness should never depend on the database or any other service. If it does, a database outage makes the platform restart a perfectly healthy process, which makes things worse. Readiness is where dependencies belong. Keep every check cheap: an indicator that calls three third-party APIs turns each monitor ping into load. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers this in more depth, along with the shutdown half of the story. In Quarkus, `quarkus.shutdown.timeout` sets how long it waits for in-flight requests to finish when you press Restart.

Test from the console or your own machine:

```bash
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/q/health/ready
200
```

And do not expose more than health on a public address. Metrics, the Quarkus dev UI and the Micronaut `/env` and `/beans` endpoints reveal configuration. The Quarkus dev UI is only active in dev mode, but double check that nothing you added to `application.properties` enables management paths in production.

#### Behind the proxy: TLS and real client addresses

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 real client address arrives in `X-Forwarded-For`.

Neither framework trusts forwarded headers by default, for the sound reason that any client could send them. Without the setting you see the proxy's address as the client, and redirects built from the request come out as `http://`.

Quarkus - application.properties:

```properties
quarkus.http.proxy.proxy-address-forwarding=true
quarkus.http.proxy.allow-x-forwarded=true
quarkus.http.proxy.enable-forwarded-host=true
```

For Micronaut, the key that names the header carrying the client address is `micronaut.server.client-address-header`:

Micronaut - application.yml:

```yaml
micronaut:
  server:
    client-address-header: X-Forwarded-For
```

Only enable these when you are genuinely behind a proxy, since anyone who can reach the application port directly could then spoof their address. After setting them, send a request and print the resolved client address from a debug endpoint rather than assuming. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the whole chain.

#### Deploying and starting it on RE:NODE

The deploy path is the same as for any jar. Build in CI or locally and run your tests there. A Quarkus or Micronaut build is lighter than a native one but still wants more memory than a small production plan should spend on it, and building where you test keeps the artefact reproducible.

1. Build the artefact: `./mvnw package` for Quarkus (then archive `target/quarkus-app`), or `./gradlew shadowJar` for Micronaut.
2. Upload it through the file manager or SFTP. Both are on every app server, with SFTP credentials per server. The file manager unpacks archives in place, which suits the Quarkus directory.
3. Set environment variables on the Startup tab: the port, the datasource values, `JAVA_TOOL_OPTIONS`, and the profile (`QUARKUS_PROFILE=prod` or `MICRONAUT_ENVIRONMENTS=prod`).
4. Point the start settings at the right file, then start from the console and watch for the framework's started line.

The alternative is deploys from GitHub. The panel does this through a GitHub App with short-lived tokens, so private repositories work, with two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. It is GitHub only. That is a source-to-server route, so it suits you only if the server will hold the build output; the practical pattern is a branch that contains just the release artefact. Before linking, the pre-flight check reads the repository and lists what will break, worst first, such as binding the wrong port or host.

A new Java server starts with a small working page on its address until you deploy code, and that page never overwrites your files.

```diagram
title: From a build to a running Quarkus or Micronaut service
ci = CI build | mvn or gradle
art = Artefact | quarkus-app or -all.jar
jvm = JVM | heap flags, port
health = Health | /q/health or /health
proxy = Proxy slot | TLS, X-Forwarded-For
users = Clients
ci -> art : upload
art -> jvm : java -jar
jvm -> health
users -> proxy : HTTPS
proxy -> jvm : HTTP
```

#### Troubleshooting

**`Error: Unable to access jarfile quarkus-run.jar` or a missing `lib` error.** You uploaded the launcher without the rest of the `quarkus-app` directory. Upload and unpack the whole directory, or switch to `uber-jar`.

**`no main manifest attribute, in app.jar`.** You uploaded a thin jar. For Micronaut that is the plain jar rather than the `-all.jar`; for Quarkus it is the wrong file in `target/`. Look for the runnable one.

**`UnsupportedClassVersionError`.** The jar was compiled for a newer Java than the server runs. Compile with `--release 17`, or choose Java 21 or 25 for the server.

**The server restarts with no stack trace.** The process went over the container limit. Lower `MaxRAMPercentage`, cap direct memory with `-XX:MaxDirectMemorySize`, and compare the memory graph to 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 accumulates. Raise it if the plan has room, or capture a dump with `-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof` and delete the file afterwards, as it is as large as the heap.

**`java.lang.OutOfMemoryError: Direct buffer memory`.** Netty ran out of off-heap space, usually under a flood of large requests or responses. Raise `-XX:MaxDirectMemorySize` if there is room on the plan, and look for code that holds buffers.

**Port already in use, or bad gateway while the app is up.** The app listens on `8080` rather than the allocated port, or on loopback. Set the port from the environment and `quarkus.http.host=0.0.0.0`.

**`/q/health` returns 404.** The `smallrye-health` extension is not in the build, or `quarkus.http.non-application-root-path` moved it.

**`/health` returns 404 on Micronaut.** `micronaut-management` is not on the classpath, or `endpoints.health.enabled` is false.

**Native image works locally but fails on the server.** It was built for a different operating system or CPU architecture. Build it in CI in a Linux container, or go back to JVM mode.

#### FAQ

##### Is fast-jar better than an uber-jar?

For start-up and incremental deploys, yes. For a deploy path that needs a single file, no. Fast-jar is the default for good reason, but an uber-jar is not wrong on a small service: set `quarkus.package.jar.type=uber-jar` and upload one file.

##### Should I use a native image on a small plan?

Usually not. The build needs several gigabytes of memory and minutes of CPU, the Java line is documented for running jars, and JVM mode with a trimmed heap already fits the cheapest tier. Native pays off for scale-to-zero and for very many small instances, not for one service.

##### How much memory does a Quarkus or Micronaut service need?

A small REST service with a database commonly runs in a heap of 128 to 256 MB and a process well under 1 GB, so the 1 GB tier is workable. Measure with the console's memory graph over a day of real traffic rather than trusting any rule of thumb, including this one.

##### Do I need `-Xmx` or `MaxRAMPercentage`?

Use `MaxRAMPercentage` if you may change plan, because it follows the limit. Use `-Xmx` for a fixed, measured value. If both are set, `-Xmx` wins.

##### Why does my health endpoint answer on one port and not another?

The management interface may be on its own port. In Micronaut `endpoints.all.port` does that, and Quarkus can serve management on a separate interface when configured. A plan has the ports it lists, so check the Network tab before moving anything.

##### Can I run Ktor or Spring Boot the same way?

Yes. Any jar runs on the Java line, and the heap flags are identical. The Spring Boot specifics are in the [Spring Boot deployment guide](https://www.renode.space/blog/deploy-spring-boot-app-to-production).

Java plans start at $4 a month and run any jar on Java 17, 21 or 25, Quarkus and Micronaut included. Every tier has an allocated port, database and backup slots, a proxy slot with a certificate issued and renewed for you, and deploys from GitHub on start or on push.

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

---

### Gunicorn and uvicorn workers on a small server

Published 2026-10-17, updated 2026-10-17 by RE:NODE Team. https://www.renode.space/blog/python-gunicorn-and-uvicorn-workers-on-a-small-server

On a small server the right number of gunicorn workers is usually one or two, not the `(2 x cores) + 1` from the documentation, and the settings that matter after that are the ones nobody sets: a `timeout` that matches your slowest honest request, `max_requests` with jitter to retire leaky workers, `preload_app` only if your code survives a fork, and a `keepalive` that suits the proxy in front of you. Uvicorn needs fewer settings but has the same shape: `--workers` for processes, `--timeout-keep-alive`, `--limit-max-requests`. This post is about configuring and operating them. How much memory each worker costs is a separate question, covered in [how much RAM a Python app needs](https://www.renode.space/blog/how-much-ram-does-a-python-app-need).

Everything here was written for a container with a fixed memory limit and a hard CPU share - the situation on a $4 to $7 Python plan - but it applies to any small VPS. If you are still working out the basics of getting an app online, start with [deploy FastAPI or Flask](https://www.renode.space/blog/deploy-fastapi-or-flask) or [deploy Django to production](https://www.renode.space/blog/deploy-django-to-production) and come back.

#### What gunicorn and uvicorn actually run

Gunicorn is a process manager that happens to speak HTTP. There is one **master** process that never touches a request. It listens on the socket, forks **workers**, watches them, and replaces any that die or stall. Each worker is a full Python interpreter with its own copy of your application, its own imported modules, its own database connections and its own caches. Nothing is shared between workers except the listening socket, which is why a counter in a module-level variable counts per worker, and why an in-memory cache is really N separate caches.

Uvicorn is a server for ASGI applications (FastAPI, Starlette, Django with `asgi.py`). A single uvicorn process runs one event loop and serves many requests concurrently, as long as your code awaits instead of blocking. With `--workers N` it becomes a small supervisor like gunicorn's master: it spawns N processes, each with its own loop, and respawns any that exit.

```diagram
title: Process layout of a typical small deployment
proxy = Proxy | TLS, forwards HTTP
master = Gunicorn master | forks, watches, never serves
w1 = Worker 1 | your app
w2 = Worker 2 | your app
db = Database | one pool per worker
proxy -> master : bind 0.0.0.0:8000
master -> w1 : fork
master -> w2 : fork
w1 -> db
w2 -> db
```

Two consequences shape every decision below. First, **each worker costs a full copy of your app in memory**, so on a plan with a memory limit the worker count is a budget, not a tuning knob. Second, **concurrency inside a worker depends on the worker class**: a sync worker handles exactly one request at a time, a threaded worker handles one per thread, and an async worker handles as many as its event loop can interleave.

The default gunicorn command binds `127.0.0.1:8000` with one sync worker. Inside a container that address is unreachable from outside, so the first change on any host is `--bind 0.0.0.0:<port>`, with the port taken from the environment. On a RE:NODE Python server the allocated port is shown on the Startup tab; the sibling posts use `SERVER_PORT` for it, so check the name on your own tab before copying a line.

#### Choosing the worker count

The documentation's `(2 x cores) + 1` assumes cores that are yours and memory you have not counted. Neither holds on a small plan. A CPU share is a hard throttle: a plan with half a core gives the whole process tree half a core of time, however many workers you start. Extra workers do not make the app faster; they split the same half core more ways and multiply the memory bill.

Work from the two limits in turn.

1. **CPU.** For a CPU-bound app, workers beyond `ceil(cpu share)` buy nothing. For an app that mostly waits on a database or an upstream API, the number of workers matters less than the number of requests in flight per worker, which is what threads and async are for.
2. **Memory.** Measure the resident size of one worker after it has served real traffic, multiply by the worker count, add the master (a few tens of MB) and keep at least a quarter of the limit free for request bursts and the interpreter's own growth.

```bash
$ ps -o pid,rss,cmd -C gunicorn
  PID   RSS CMD
 4121 31204 gunicorn: master [app.main:app]
 4128 96740 gunicorn: worker [app.main:app]
 4129 97112 gunicorn: worker [app.main:app]
```

`RSS` is in kilobytes. Run it after a few hundred requests, not at startup - a worker that has not yet imported lazily loaded modules or warmed its caches looks smaller than it will be an hour later.

A starting point for the Python line, assuming roughly 100 MB per worker, which is typical for a FastAPI or Flask app with an ORM and a database driver (yours may be double that):

| Plan | Memory / CPU share | Starting config |
| --- | --- | --- |
| Starter | 1 GB / 0.5 core | 1 worker, 4 threads or async |
| Standard | 2 GB / 1 core | 2 workers, 2-4 threads each |
| Advanced | 4 GB / 1.5 cores | 2-3 workers |
| Pro | 6 GB / 2 cores | 3-4 workers |

The ladder is on the [Python plans](https://www.renode.space/app-hosting#python), from $4 a month. Notice that memory is rarely the first limit on the larger rows - the CPU share is. Past two or three workers on a single-digit share of cores you are mostly buying context switches.

There is one more reason to prefer two workers over one when you can afford it: a single worker is a single point of stall. If it hits a slow request, a garbage collection pause or a restart, the service is unreachable for that whole moment. Two workers mean the other one keeps answering while the first recovers. On a 1 GB plan that trade is often not available, which is exactly when threads or async earn their place.

> [!TIP]
> Both gunicorn and uvicorn read the `WEB_CONCURRENCY` environment variable as the default worker count. It is a handy switch for the Startup tab, because you can change the count without editing the start command or redeploying code.

#### Worker classes: sync, gthread and uvicorn

Gunicorn's `worker_class` decides how a worker handles requests, and it is the biggest lever after the count.

| Worker class | Concurrency per worker | Use for |
| --- | --- | --- |
| `sync` (default) | 1 request | Flask or Django, CPU-bound, plenty of workers |
| `gthread` | `threads` requests | Flask or Django waiting on a database |
| `uvicorn_worker.UvicornWorker` | Event loop | FastAPI, Starlette, Django ASGI |

**`sync`** is simple and predictable, and it is also the one that most needs a buffering proxy in front: a sync worker is occupied for as long as a client takes to send its request, so a slow connection pins a whole worker. Behind a proxy that reads the full request before forwarding it, this is not a problem.

**`gthread`** gives each worker a pool of threads. Four threads in one process serve four requests that are all blocked on a database for roughly the memory of one process, which is the best value setting for an I/O-bound WSGI app on a small plan:

```bash
$ gunicorn wsgi:app --bind 0.0.0.0:8000 \
    --workers 2 --worker-class gthread --threads 4
```

Mind the arithmetic downstream: two workers with four threads each can hold eight database connections at once. Size your pool and your database plan to that, not to the worker count. Setting `--threads` above 1 with the default `sync` class switches the class to `gthread` for you in current releases, but writing it out removes the guesswork.

**Uvicorn workers under gunicorn** give you gunicorn's supervision with uvicorn's event loop:

```bash
$ gunicorn app.main:app -k uvicorn_worker.UvicornWorker \
    --bind 0.0.0.0:8000 --workers 2
```

This needs the `uvicorn-worker` package. The older `uvicorn.workers.UvicornWorker` that shipped inside uvicorn is deprecated and may be gone in the version you pin, so check `pip show uvicorn` before copying a line from an old tutorial. Alternatively skip gunicorn:

```bash
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000} --workers 2
```

Which to pick? Uvicorn's own `--workers` is one dependency fewer and perfectly adequate for a small app. Gunicorn adds a mature set of operational features: `max_requests` with jitter, per-worker timeouts that kill a stuck worker, `post_fork` hooks, a long-established signal protocol and finer log control. Uvicorn has been closing that gap in recent releases (see the reload section), but if you need to be sure a hung worker is killed, gunicorn is the safer supervisor today.

One rule for async workers: **never block the event loop**. A synchronous database call or `requests.get` inside an `async def` handler stops every other request in that process until it returns. A blocked loop does not raise an error. It shows up as latency, and under gunicorn eventually as a `WORKER TIMEOUT`, because the loop also drives the worker's heartbeat to the master.

#### Timeouts that kill requests

Gunicorn's `timeout` (default 30 seconds) is widely misunderstood. It is not a request timeout. It is the longest a worker may go without telling the master it is alive. For sync workers that heartbeat happens between requests, so it behaves like a per-request limit: a request that takes 31 seconds gets its worker killed. The log line is unmistakable:

```text
[2026-10-17 09:14:02 +0000] [4121] [CRITICAL] WORKER TIMEOUT (pid:4128)
[2026-10-17 09:14:03 +0000] [4128] [INFO] Worker exiting (pid: 4128)
[2026-10-17 09:14:03 +0000] [4140] [INFO] Booting worker with pid: 4140
```

For `gthread` and async workers the heartbeat comes from a different place, so a slow request does not by itself trip the timeout; a blocked main thread or event loop does. That difference matters when you debug: a `WORKER TIMEOUT` on an async worker means something froze the loop, not that one request ran long.

These are the timeouts in play and who owns them:

| Setting | Default | What it limits |
| --- | --- | --- |
| gunicorn `timeout` | `30` | Silence from a worker before it is killed |
| gunicorn `graceful_timeout` | `30` | How long a stopping worker may finish requests |
| gunicorn `keepalive` | `2` | Idle seconds on a keep-alive connection |
| uvicorn `--timeout-keep-alive` | `5` | Idle seconds on a keep-alive connection |
| uvicorn `--timeout-graceful-shutdown` | none | Wait for connections on shutdown |

Raising `timeout` to 120 because one report endpoint is slow is the common wrong answer. It also lets every genuinely stuck worker sit idle for two minutes, on a plan where there are only two of them. The better options, in order:

- Make the slow thing fast: an index, a smaller query, a cache.
- Move it out of the request: return `202 Accepted` and do the work in a background job, then poll for the result.
- If it truly must be synchronous, raise `timeout` and **say why in a comment next to it**, and keep threads or an extra worker so that one long request does not take the whole app with it.

Uvicorn has no request timeout at all. If an upstream call hangs, the handler hangs, so set timeouts on every outbound client (`httpx.AsyncClient(timeout=10)`, a database `statement_timeout`) rather than expecting the server to rescue you. The proxy in front also has its own read timeout, usually longer than gunicorn's default; if the proxy gives up first, the client sees a 504 while your worker is still busy for another few seconds.

Finally, `worker_tmp_dir`. Gunicorn writes heartbeat files to a temporary directory, and in containers whose temp directory is on a slow or overlay filesystem those writes can block and cause spurious timeouts. The documented fix is a memory-backed directory:

```bash
$ gunicorn app.main:app --worker-tmp-dir /dev/shm
```

#### Preload, hooks and the fork trap

By default each worker imports your application itself, after the fork. With `--preload` (or `preload_app = True`) the master imports it once, before forking. Two things follow.

**What you gain.** Start-up errors surface in the master straight away, instead of every worker failing to boot in a loop. Workers also start faster, and the pages holding imported code can be shared copy-on-write between processes, so total memory is lower than N independent imports.

**What you should not count on.** Python's reference counting writes to objects as they are used, which dirties pages and un-shares them over time. The saving is real but partial: expect it to shrink as workers age. Measure resident memory with and without `--preload` before deciding it is worth the complication.

**What you pay.** Anything created at import time is created once and then inherited by every worker. That is harmless for configuration and disastrous for connections:

- A database engine or connection that was opened at import is now shared by several processes using the same underlying socket. Queries interleave, and you get errors that appear only under load and only some of the time.
- Threads started at import exist only in the master. The workers never get them.
- Random seeds and other per-process state are copied, so workers behave identically where you expected them not to.

The fix is to create connections after the fork, in a hook. Gunicorn's config file is plain Python, so hooks are functions:

gunicorn.conf.py:

```python
def post_fork(server, worker):
    # runs in each worker after the fork, before it serves anything
    from app.db import engine
    engine.dispose(close=False)
```

`engine.dispose(close=False)` is the SQLAlchemy 2.x way of discarding the pool inherited from the master without closing connections the master still owns. For any other client library, the pattern is the same: throw away what the master created and let each worker open its own. If your app opens connections lazily on first use, as most ORMs do, preload is usually safe as it stands.

Preload has one more interaction that bites people during deploys, covered in the next sections: with `--preload`, a `HUP` signal re-reads the config but does **not** re-import your code, because the code was loaded in the master and the master did not restart.

#### Keepalive and the proxy in front

HTTP keep-alive lets a client reuse one TCP connection for many requests. For a small app the connection that matters is between the proxy and your server. On RE:NODE app and web plans include a reverse-proxy slot that terminates TLS and forwards plain HTTP to your port, passing the real client address in `X-Forwarded-For`. See [what a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) for the general picture.

The two settings:

- **Gunicorn `keepalive`**, default 2 seconds, applies only to the `gthread` and async worker classes. A `sync` worker closes the connection after every response, because it cannot afford to sit waiting on an idle connection while it is the only thing serving traffic.
- **Uvicorn `--timeout-keep-alive`**, default 5 seconds.

The classic bug is a race. The proxy keeps an idle connection open to your app and sends a request on it at the same instant your server decides the connection has been idle long enough and closes it. The proxy sees a reset and returns a 502 for a request that never reached your code. It is intermittent, it rises with low traffic rather than high, and nothing appears in your app log. The rule is that **the app's keep-alive timeout must be longer than the proxy's idle timeout towards the app**. Check how long your proxy keeps upstream connections idle; the proxy slot's value is not something this post can tell you, so measure instead. If you see occasional 502s with a clean app log, raise `keepalive` to 10 or so as an experiment and watch whether they stop.

Setting it very high has a cost: every idle connection occupies a slot. In a `gthread` worker, a connection held open occupies a thread. With four threads and a generous keep-alive, four idle connections can leave the worker with nobody to serve a fifth client. Keep it modest, 5 to 10 seconds, and keep the thread count above the number of proxy connections you expect.

Trusting the forwarded headers is the other half. Both servers only believe `X-Forwarded-For` and `X-Forwarded-Proto` from addresses they trust: gunicorn through `forwarded_allow_ips` (environment `FORWARDED_ALLOW_IPS`), uvicorn through `--forwarded-allow-ips`. Both default to `127.0.0.1`, which does not match a proxy that connects from another address. The result is that every request appears to come from the proxy and arrives as plain HTTP, so redirect URLs are built with `http://` and your logs show one visitor. Setting the value to `*` fixes it, and is only acceptable if the app's port cannot be reached except through the proxy. If you are unsure whether it can, put the proxy's address in the list instead and check what your server logs as the peer address.

#### max-requests and slow leaks

`max_requests` tells gunicorn to retire a worker after it has served that many requests and replace it with a fresh one. It is a blunt, effective tool against a slow leak in your code or a dependency you do not control, and against the gradual fragmentation that makes long-running Python processes larger over time. Always pair it with jitter:

gunicorn.conf.py:

```python
max_requests = 1000
max_requests_jitter = 100
```

The jitter adds a random number from 0 to 100 to each worker's limit, so they do not all hit it on the same request. Without it, on a two-worker plan, both workers can recycle within moments of each other, and there is a window in which none is ready to serve. A worker being replaced finishes the request it is on first, then exits, so users do not see an error; they see, at worst, a slightly slower response while the new worker boots.

Pick a number from your own memory graph rather than from a blog. Watch the Console tab's memory graph over a day. If a worker's resident size climbs steadily with request count, set `max_requests` so that a worker is retired well before it reaches the point where the plan runs out of headroom. If memory is flat, you do not need it, and recycling just throws away warm caches and import work for nothing.

Uvicorn has the same thing as `--limit-max-requests N`: the process exits after N requests. Under gunicorn's supervision it is respawned. With uvicorn's own `--workers`, recent releases respawn it too, but older ones do not, which would leave your service with fewer processes after each recycle. Check the release you pinned before relying on it, and prefer gunicorn if you cannot confirm it.

`max_requests` hides a leak; it does not fix it. If you reach for it, write down why, and look at the cause when you have time. The memory side of this is in [Node and Python memory limits](https://www.renode.space/blog/node-memory-limits-explained) for the Node equivalent, and [game server memory leaks](https://www.renode.space/blog/game-server-memory-leaks) describes the discipline of telling a leak from normal growth.

#### Reloading without dropping requests

Gunicorn's master responds to signals, and they are the whole operational interface:

| Signal | Effect |
| --- | --- |
| `HUP` | Re-read config, start new workers, retire old ones gracefully |
| `TERM` | Graceful shutdown, waits up to `graceful_timeout` |
| `INT`, `QUIT` | Immediate shutdown |
| `TTIN`, `TTOU` | Add or remove one worker |
| `USR1` | Reopen log files (after rotation) |
| `USR2` | Start a new master alongside the old one |

`HUP` is the one for deploys. New workers boot with the new code and config **before** the old ones are told to stop, and the old ones finish what they are doing, so in-flight requests complete. On your own machine:

```bash
$ gunicorn app.main:app -c gunicorn.conf.py --pid /run/myapp.pid
$ kill -HUP $(cat /run/myapp.pid)
```

Under systemd, which is the sensible way to run this on a VDS, expose it as a reload action:

/etc/systemd/system/myapp.service:

```ini
[Service]
User=app
WorkingDirectory=/srv/myapp
ExecStart=/srv/myapp/.venv/bin/gunicorn app.main:app -c gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=40
```

`systemctl reload myapp` then sends the `HUP`. `TimeoutStopSec` should exceed `graceful_timeout`, or systemd kills the process while workers are still draining. There are some caveats you have to know before depending on it:

- **With `preload_app`, `HUP` does not pick up new code.** The code lives in the master. To deploy new code under preload, restart, or use the `USR2` handover (start a new master, then send `WINCH` and `QUIT` to the old one), which is intricate enough that most small deployments choose a restart.
- **A broken new release can take the service down.** If the new code fails to import, new workers fail to boot (`Worker failed to boot`) while the old ones are shut down. Run `python -c "import app.main"` or `gunicorn --check-config` before the signal.
- **New dependencies need installing first.** `HUP` does not run `pip`. See [Python requirements and virtualenvs](https://www.renode.space/blog/python-requirements-and-virtualenvs).
- **Database schema changes are your problem.** Old and new workers run side by side for a few seconds, so a migration that breaks the old code will break it.

Uvicorn grew similar behaviour in its multi-process mode: recent releases restart workers one by one on `SIGHUP` and add or remove workers on `SIGTTIN` and `SIGTTOU`. As with other uvicorn details, confirm it in the release notes of the version you pin.

On a panel host the picture is different. The panel's Restart button is a full stop and start of the server process, which is a clean, simple and safe way to deploy, and it will drop connections for the few seconds the app takes to boot. The panel console types into the process rather than sending it signals, so a `HUP` is not something to expect from it; we have not confirmed a signal option there. For a deploy with no gap on a small plan, read [zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server), and [graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) for what a stopping process owes its clients. If the Git deploy is switched on, a push restarts the server, so the same boot-time gap applies.

#### A complete config for a small plan

Put the settings in `gunicorn.conf.py` in the application's working directory, where gunicorn looks by default, so the start command stays short and the settings are reviewed in git like everything else. This one suits a FastAPI app on a Standard plan (2 GB, 1 core):

gunicorn.conf.py:

```python
import os

bind = f"0.0.0.0:{os.environ.get('SERVER_PORT', '8000')}"
workers = int(os.environ.get("WEB_CONCURRENCY", "2"))
worker_class = "uvicorn_worker.UvicornWorker"

timeout = 30
graceful_timeout = 30
keepalive = 10

max_requests = 1000
max_requests_jitter = 100

worker_tmp_dir = "/dev/shm"
forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")

accesslog = "-"
errorlog = "-"
loglevel = "info"
capture_output = True
```

The start command is then just `gunicorn app.main:app -c gunicorn.conf.py`. Logs go to standard output and error, which is where the Console tab reads them; there is nothing to rotate. For a Flask or Django app on the same plan, swap in `worker_class = "gthread"`, add `threads = 4`, and drop the uvicorn line.

Check the file before you start the server, and when it will not start:

```bash
$ gunicorn app.main:app -c gunicorn.conf.py --check-config
$ gunicorn app.main:app -c gunicorn.conf.py --print-config | grep -E "^(workers|timeout)"
```

The first catches typos in setting names and import errors; the second prints what gunicorn actually resolved, after environment variables and the command line have been applied. Command-line flags override the file, which is how you end up debugging a setting that is "clearly" in the config and not taking effect.

#### Troubleshooting

**`WORKER TIMEOUT` repeating.** A worker went silent for longer than `timeout`. For sync workers, find the slow request (log request duration with `%(D)s` in `access_log_format`). For async workers, look for blocking calls inside `async def`. If it only happens at boot, the app is taking longer than `timeout` to import; preload helps, as does raising `timeout` for start-up only.

**`Worker (pid:N) was sent SIGKILL! Perhaps out of memory?`** Something killed a worker without gunicorn's involvement, and on a memory-limited plan the memory limit is the first suspect. At the limit the kernel stops processes and the container restarts clean rather than swapping. The fix is fewer workers, fewer threads or a smaller app, in that order. Repeated restarts are noticed: three in an hour puts a warning on the server page and opens a ticket, and six suspend the server.

**`Worker failed to boot` and the master exits.** The import failed. Run the same import by hand with the venv's interpreter and read the traceback. Without `--preload` this appears once per worker, which is noisy but harmless.

**Occasional 502s from the proxy with a clean app log.** The keep-alive race described above. Raise `keepalive` or `--timeout-keep-alive` and see.

**Everything slow at 100% CPU, plenty of memory free.** You are CPU-throttled. More workers make it worse. Profile the app, cache, or move to a plan with a larger CPU share. A server at 100% CPU is slow, never suspended for it.

**Database errors only under load.** Either connections were opened before the fork (`--preload`) or the product of workers, threads and pool size exceeds what the database allows. Count it: workers times threads times pool size.

**Redirects go to `http://`.** The server is not trusting `X-Forwarded-Proto`. Set `forwarded_allow_ips` as described above, and in Django set `SECURE_PROXY_SSL_HEADER`.

#### FAQ

##### How many gunicorn workers should I run on a 1 GB plan?

One, with several threads or an async worker class, unless a measurement shows your app uses under about 200 MB per worker in total. A second worker buys resilience to a single stall, but on 1 GB with 0.5 core it often costs more in memory and CPU contention than it saves.

##### Is uvicorn with `--workers` enough, or do I need gunicorn?

For a small app, uvicorn alone is enough and has one fewer dependency. Choose gunicorn when you want the stronger supervision: a timeout that kills a hung worker, `max_requests` with jitter, hooks and a long-standing signal interface.

##### Should I turn on preload?

Only if your startup is heavy or you want import errors in the master, and only after checking that nothing opens a connection or starts a thread at import time. Measure memory with and without it; the copy-on-write saving in Python is smaller than people expect.

##### What is a sensible `max_requests`?

Whatever retires a worker before its memory reaches your headroom. A thousand with a jitter of a hundred is a common starting point. If your memory graph is flat you do not need it at all.

##### Why does gunicorn kill a request that runs for 31 seconds?

Because `timeout` defaults to 30 and a sync worker that has not answered by then is presumed stuck. Make the request faster or move it to a background job before raising the limit, and raise it only for as long as you need to.

##### Can I reload gunicorn without downtime on a panel host?

Not with a signal that we have confirmed. Restart is a clean stop and start with a few seconds of gap. If you need no gap, run two instances behind something that can drain one, or move to a VDS where you control systemd and can send `HUP`.

A Python server on RE:NODE starts from $4 a month with 1 GB of memory and a Startup tab for your start command and environment variables. The console shows live output and graphs for memory and CPU against the limits, so you can measure a worker before you decide how many to run, and Git deploy restarts the server when you push.

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

---

### AI context files in your repo: CLAUDE.md, AGENTS.md and the server

Published 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/ai-context-file-in-your-repo

An AI coding assistant opened in a repository knows the code and nothing else. It does not know which port the server listens on, that only one directory survives a restart, that there is no root and no systemd, or that a push redeploys within seconds. So it guesses, and the guesses are the defaults from tutorials: port 3000, `localhost`, a Dockerfile nobody asked for, a password pasted into a config file. The fix is a plain Markdown file in the repository that the assistant reads before it starts - `CLAUDE.md` for Claude Code, `AGENTS.md` for Codex, Cursor and a growing list of others. On RE:NODE app servers linked to GitHub, the panel can write that file for you: switch on **Keep AI context in the repository** on the GitHub tab and it opens a pull request adding `.renode/server.md` and a short marked block in `CLAUDE.md` and `AGENTS.md`. You read it, you merge it, and from then on the assistant knows how the server runs without anyone pasting it in.

This post covers both halves: what a context file is and how to write a good one on any host, and exactly what the panel writes, what it leaves out, and how the pre-flight check feeds into it.

#### What an AI context file is

A context file is instructions for an agent, kept next to the code. When an agentic tool starts a session in a repository, it looks for a file with a known name at the root (and sometimes in subdirectories), reads it, and treats it as standing instructions for the whole session. It is not configuration in the strict sense - nothing parses it - it is prose and lists the model reads the same way it reads your prompt, except you only write it once.

What goes in it is the knowledge a new colleague would need on day one and would otherwise have to ask for:

- how to install, build, test and run the project, as exact commands;
- where things live, and which directories are generated and must not be edited;
- the conventions that are not obvious from the code - naming, error handling, which library wins when two would do;
- the environment the code actually runs in, and its limits;
- the things that must never happen: secrets in commits, edits to vendored code, force pushes.

The last two items are where most context files are thin, and where most damage comes from. An assistant that does not know the deployment environment writes code for a laptop. Code for a laptop listens on `127.0.0.1`, writes uploads to `/tmp`, and assumes it can `apt install` whatever it needs. All three fail quietly on a container host. We covered the general failure modes of agents doing server work in [AI coding agents for server and DevOps work](https://www.renode.space/blog/ai-coding-agents-for-server-work); a context file is the cheapest guardrail against the "wrong environment" class of mistake.

#### Which tools read which file

There is no single standard yet, and the file names are a small mess. The useful summary as of late 2026:

| File | Read by |
|---|---|
| `CLAUDE.md` | Claude Code |
| `AGENTS.md` | OpenAI Codex, Cursor, GitHub Copilot's agent and others |
| `.cursor/rules/` | Cursor (its own rule files) |
| `.github/copilot-instructions.md` | GitHub Copilot |

`AGENTS.md` is the closest thing to a shared format: a plain Markdown file with no required structure, which several vendors agreed to read. `CLAUDE.md` is Claude Code's own name for the same idea; it also reads `CLAUDE.md` files in subdirectories when it works in them, and a personal one in your home directory. Tool support moves quickly, so if one of these matters to you, check the tool's own documentation for which names it currently picks up.

Two practical consequences. First, if your team uses more than one assistant, you need the same core instructions in more than one file. Keeping them identical by hand does not last; the usual answer is to keep the shared part short and point both files at one longer document. Second, a chat assistant such as ChatGPT in a browser does not read your repository on its own at all. It only knows what you give it - which is why the panel also has a share link for that case, covered at the end.

#### What belongs in one, and what does not

The best context files are short and specific. A model reads every line of the file on every session, and a long file full of general advice dilutes the few lines that matter. A useful test for each line: would an assistant that had never seen this file get this wrong? If not, delete it. "Write clean code" fails that test. "The server listens on the port in `SERVER_PORT`, not 3000" passes it.

A small Node service might have this:

CLAUDE.md:

```text
# Orders API

## Commands
- Install: npm ci
- Test: npm test (needs a local Postgres, see docker-compose.yml)
- Start: npm start (runs node dist/server.js; build first with npm run build)

## Layout
- src/ is the code; dist/ is generated - never edit it.
- migrations/ is append-only. Add a new file; never change a merged one.

## Rules
- Read configuration from environment variables only. See .env.example.
- Never commit .env or any key, token or password.
- Use the existing logger in src/log.ts, not console.log.
```

What does not belong in it:

- **Secrets.** A context file is committed, so anything in it is in the history for good. Name the variable; never write its value. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers where the values should live instead.
- **Things that change on every deploy.** The current commit, the last deploy time, a build number. They go stale immediately and make every diff noisy.
- **Things the code already says.** The list of routes, the database schema. The assistant can read those; copying them into prose just creates a second version to drift.
- **Long tutorials.** Link to a document in the repository instead, and let the assistant open it when it needs it.

The part most people leave out is the server. They write commands and conventions, then stop, because the server feels like somebody else's problem. It is the part the panel can fill in, because the panel is the one place that knows it exactly.

#### The AI context switch on the GitHub tab

On an app server linked to a GitHub repository, the GitHub tab has a switch: **Keep AI context in the repository (opens a pull request)**. It is off until you turn it on, and it applies to that one link. Nothing is ever opened for a repository whose owner has not clicked it.

When it is on, the panel's GitHub App - the same one that handles [deploys from GitHub](https://www.renode.space/blog/deploy-a-node-app-from-github) - does the following:

1. It creates a branch called `renode/ai-context` from the head of the branch your server deploys from.
2. It commits `.renode/server.md` and the marked blocks in `CLAUDE.md` and `AGENTS.md` to that branch, through GitHub's API. Nothing is cloned.
3. It opens a pull request from `renode/ai-context` into your deploy branch.
4. You review it and merge it, or close it.

The panel never pushes to the branch your server deploys from. Every commit goes to its own `renode/ai-context` branch, and merging is your decision. Pushes to `renode/` branches are also ignored by the deploy-on-push hook, so refreshing the context never restarts the server.

> [!NOTE] The App needs two repository permissions for this that plain deploys do not: Contents read and write, and Pull requests read and write. If your installation has not approved them yet, the tab says "Waiting for permission on GitHub", names the two permissions and links to the installation's settings page. Approve them there, then press **Update now**.

The status line under the switch tells you where things stand: off, working on it, waiting for permission, a pull request open against your branch, up to date, the last pull request closed without merging, or an error. Every refusal from GitHub - a rate limit, a protected branch, an archived repository, a missing permission - shows up as one of those lines rather than a failure page. The general steps for linking a repository in the first place are in the [deploys guide](https://www.renode.space/guides#deploys).

#### What the panel writes into the repository

Two kinds of content, with different ownership.

##### The block in CLAUDE.md and AGENTS.md

In `CLAUDE.md` and `AGENTS.md` the panel owns only the text between two HTML comments, `<!-- renode:start -->` and `<!-- renode:end -->`. If the file does not exist, it is created holding just the block. If it exists without the markers, the block is appended at the end. If it has the markers, only what is between them is replaced. Everything you wrote outside them survives byte for byte, so your own commands and conventions sit above or below the panel's part without being touched. HTML comments do not render on GitHub, so the markers are invisible when someone reads the file there.

The block is deliberately short - the handful of rules that break a deploy when ignored, and a pointer to the full file. For a Node.js server with deploy on push it reads roughly like this:

CLAUDE.md (the panel's block):

```text
<!-- renode:start -->
## Renode server

This repository is deployed to a Renode server (Node.js) from the main
branch. Read .renode/server.md before changing how it builds, starts or
connects to anything.

- Listen on 0.0.0.0 on the port in the SERVER_PORT environment variable.
  No other port is reachable from outside.
- Only /home/container persists. No root, no apt, no systemd, no Docker.
- The panel starts it in a fixed order. Keep the entry file and start
  script it expects.
- Never commit secrets. They go in a .env uploaded through the Files tab,
  or in Startup variables, and are read from the environment.
- A push to main redeploys and restarts the server within seconds.
- Fix the code, not the startup command or the Docker image.
<!-- renode:end -->
```

The exact wording depends on the server: the runtime name, the branch, the number of boot steps, and whether deploy on push is on. With it off, the push line instead tells the assistant to restart the server from the panel after pushing.

##### The server file

`.renode/server.md` belongs to the panel entirely, says so in a comment at the top, and is overwritten whenever the server's settings change. Put your own notes in `CLAUDE.md` or `AGENTS.md`, outside the markers, not here. It has these sections:

| Section | What it holds |
|---|---|
| Runtime | the kind of server and its Docker image |
| Network | the port, plus address and domains (private repos) |
| Code | the path in the container, repo, branch, deploy switches |
| How this server boots | the numbered steps the panel runs on start |
| Variables | each environment variable, its name, whether it is secret |
| Databases | attached databases (details only in private repos) |
| Limits | memory, CPU and disk |
| Pre-flight check | what the panel found wrong in the repository |
| Rules | the full list the short block summarises |

The boot order is the most useful part, and the one nobody writes by hand. An assistant asked "why does my app not start" can read that the server installs dependencies, then runs a particular start command, and reason from there instead of inventing a `Procfile`. The limits section matters for the same reason: knowing the process is killed at its memory ceiling with no error message - the console just stops - turns a mysterious crash into an obvious diagnosis. [Node memory limits explained](https://www.renode.space/blog/node-memory-limits-explained) goes into that one in detail.

What the file leaves out on purpose: the deployed commit and its time. Including them would turn every push into a pull request update, which is exactly the noise you do not want.

#### Public and private repositories

The panel asks GitHub whether the repository is private every time it writes, and treats anything other than an explicit "private" as public. The two get different files.

| Detail | Private repo | Public repo |
|---|---|---|
| Rules, boot steps, limits | yes | yes |
| Server address and port | yes | port variable only |
| Domains, node, server ID | yes | no |
| Variable names | yes | yes |
| Variable values | non-secret ones | no |
| Database host, name, user | yes | count only |

Secret values are never written to either kind. A variable marked secret appears as a placeholder that says it is set on the server and not shown, and database passwords are never included - the file says the password is on the Databases tab and belongs in `.env`. Both versions also pass through the same redactor the panel's other AI features use, which scrubs anything shaped like a key or token and the server's own known secrets. We describe that as a net, not a promise: it catches what it was built to catch, and you should still read the pull request before you merge it, as you would any other.

> [!WARNING] If you make a private repository public later, the merged `.renode/server.md` from when it was private stays in the history with the address and domains in it. The next refresh rewrites the file without them, but it cannot rewrite the past. Treat the address as known.

#### How pre-flight findings feed the context

The GitHub tab also runs a pre-flight check: it reads the repository - one tree listing and a few small files, never a clone - and lists what will break when the server starts, worst first, each with a fix to paste. It looks for a missing start script or entry file, binding port 3000 or `localhost` instead of the server's port on `0.0.0.0`, a framework that is never built (no `.next`, say), a runtime version pinned in `.nvmrc` or `go.mod`, Python requirements and the entry file, a Go main package, Bun and Deno start tasks, an `index` file on web hosting, and a committed `.env`, which is reported by name and never opened.

When the AI context switch is on, those findings go into `.renode/server.md` under **Pre-flight check**, introduced with "What the panel found when it read this repository. Fix every FAIL first." Each finding is one line, tagged with its level in capitals, followed by the detail and the fix:

.renode/server.md (excerpt):

```text
## Pre-flight check
What the panel found when it read this repository. Fix every FAIL first.
- [FAIL] No start script and no entry file - The server runs npm run start,
  and package.json has no start script. ... Fix: Add "start": "node <your
  main file>" to the scripts in package.json.
```

This is the part that changes how an assistant behaves. Without it, you open Claude Code or Cursor and type "the deploy is broken, fix it", and the assistant starts by guessing. With it, the assistant opens the repository, reads the context file, and finds a list of concrete faults with their fixes already written. The usual first move is then to work down that list. The panel did the diagnosis; the assistant does the edit; you review the diff.

It also closes a loop. You fix a FAIL, push, and the next refresh reads the repository again. The finding is gone, the file content changes, and the open pull request gets a new commit without it. A clean repository ends up with no Pre-flight check section at all.

#### Keeping it current

The context is refreshed, not regenerated on a whim. A background task runs every ten minutes and notices when variables, ports, databases or domains change; saving settings on the GitHub tab queues a refresh too, held to at most one per link per ten minutes. Each time, the content is hashed, and if the hash matches the last one, nothing is written. A server that does not change produces no commits.

What happens to the pull request depends on its state:

- **Open**: it gets a new commit on `renode/ai-context` with the changes.
- **Merged**: a new pull request opens only when the content differs from what is on your branch.
- **Closed without merging**: the status says so, and a new one opens only when the content changes again.

**Update now** skips the wait and runs a refresh immediately - useful after approving the App's permissions or right after a settings change you want in the file today.

The link follows the server's deploy settings. Switching the deploy branch closes the pull request and opens one against the new branch. Switching to a different repository turns the feature off, because agreeing to pull requests in one repository is not agreeing to them in another. Unlinking turns it off as well. Turning the switch off yourself closes the open pull request with a comment and deletes the `renode/ai-context` branch. It never touches what you already merged: the files stay in your repository until you remove them.

#### Troubleshooting

**The status says it is waiting for permission.** The GitHub App has not been granted Contents and Pull requests write access on your installation. Follow the link on the tab to the installation's settings, accept the new permissions, then press **Update now**. Until you do, the panel writes nothing.

**It says it could not update the repository.** Read the status line and the reason with it. The common ones: the repository is archived (GitHub refuses all writes), you have hit GitHub's rate limit (wait and press **Update now**), or a branch protection rule covers `renode/*`. Protection on your main branch does not matter - the panel never writes there - but a wildcard rule that also matches `renode/ai-context` will block it.

**My CLAUDE.md has a start marker but no end marker.** The panel refuses to guess where its block ends and writes nothing. This usually happens after a hand edit or a bad merge. Delete the stray marker, or add the missing one, and press **Update now**.

**The assistant still binds to port 3000.** Check the pull request was merged into the branch you have checked out. The files only work once they are in the working copy the assistant opens. Then check the tool reads the file you think it reads - Claude Code reads `CLAUDE.md`, Codex and Cursor read `AGENTS.md`. Some tools only read context at the start of a session, so start a new one.

**I do not want the panel touching CLAUDE.md at all.** Close the pull request, or turn the switch off. If you want the information without the pull request, the "Work on this server with AI" bar on the Console tab gives you a link with the same server context to hand to an assistant directly.

#### FAQ

##### Does the panel ever push to my main branch?

No. Every write goes to `renode/ai-context` and reaches your branch only through a pull request that you merge. The code that moves branches refuses any branch outside `renode/`, and pushes to those branches never trigger a deploy.

##### Will merging the pull request restart my server?

If deploy on push is on, merging puts a new commit on your deploy branch, so yes, it redeploys and restarts like any other push. The context files themselves change nothing about how the code runs.

##### Can I edit the generated files?

Edit `CLAUDE.md` and `AGENTS.md` freely outside the markers; the panel leaves that text alone. Do not edit `.renode/server.md` or the text between the markers - the next refresh will overwrite it. Change the server's settings instead and the file follows.

##### Does this work with ChatGPT?

Not through the repository. ChatGPT in a browser does not read files from your GitHub on its own. For a chat assistant, use the **Connect AI** button on the Console tab, which makes a share link with the server's context that you paste into the conversation. Claude Code, Cursor, Copilot and Codex are the tools that pick up the repository files by themselves.

##### Is my database password in the file?

No, in neither public nor private repositories. A private repository gets the database name, host, port and user, with a note that the password is on the Databases tab and belongs in `.env`. A public one gets only the number of databases attached.

##### What if my repository is not linked to a server?

Then there is nothing for the panel to describe, and the switch is not offered. You can still write `CLAUDE.md` and `AGENTS.md` by hand using the advice above - the server section is the part to take most care over.

App servers deploy from GitHub through the panel's GitHub App, with deploy on push, a pre-flight check that reads your repository before the first start, and an opt-in pull request that writes the server's rules into `CLAUDE.md` and `AGENTS.md` for your assistant.

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

---

### Deploy a Java app: Spring Boot or plain jar on a Java server

Published 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/deploy-a-java-app

A Java application deploys as a single runnable jar. The server needs four things from you: a Java version at least as new as the one you compiled for, a jar that carries its own dependencies, code that listens on the port the server was given, and a memory footprint that fits under the plan's limit with room to spare. Everything else - environment variables, a domain, logs, deploys from GitHub - sits on top of those four. Get them right and `java -jar app.jar` is the whole story. Get one wrong and you see an error that rarely names the real cause.

This post walks the whole path on a hosted Java server: which Java version to pick, how to build a fat jar with Maven or Gradle for Spring Boot and for everything else, how the port reaches your code, how the JVM heap relates to the container limit, how to pass configuration, how to get builds from GitHub onto the server without compiling there, and how to read and keep logs. Where RE:NODE's Java line does something specific, we say so; the rest applies to any host that runs a jar in a container. For the Spring-specific production details - HikariCP, Actuator, proxy headers - there is a companion piece, [deploying Spring Boot to production](https://www.renode.space/blog/deploy-spring-boot-app-to-production).

#### How a Java server runs your jar

A hosted Java server is a container with a JDK inside it and a fixed start command. On RE:NODE's [Java line](https://www.renode.space/app-hosting#java) that command is, in full:

start command:

```bash
java -XX:MaxRAMPercentage=80.0 -Dterminal.jline=false -Dterminal.ansi=true \
  -Dserver.port=${SERVER_PORT} -jar {{JARFILE}}
```

Three things follow from it, and they shape every section below.

First, there is no build step. The server runs a jar; it does not run Maven or Gradle. You build on your own machine or in CI, and only the finished artefact goes to the server.

Second, the jar is named by a variable, `JARFILE`, which defaults to `app.jar` and is relative to the server's folder. It may include a subfolder, so `build/app.jar` or `release/service.jar` are fine, as long as the name ends in `.jar`. You change it on the Startup tab.

Third, the heap and the port are decided by the command rather than by you. The heap ceiling is 80% of the plan's memory, and the port is passed as the system property `server.port`. Spring Boot reads that property on its own; other frameworks need one line of code, covered in the port section.

The start command itself is not something the customer edits - only its variables. That is deliberate: it keeps a server's start predictable. It also means the usual tricks of adding `-Xmx` or `-XX:+ExitOnOutOfMemoryError` to the command line are not available, which matters for small plans and is dealt with in the memory section.

When a Java server is first created it is not empty: it starts with a small working page on its own address, built as `app.jar`, so the first start shows something useful instead of a crash. That page is just a jar under the default name. Upload or deploy your own `app.jar` and it replaces the page; nothing of yours is ever overwritten by it.

The panel decides a server has finished starting when a console line contains one of a few words: `Started`, `Listening`, `Ready` or `Server running` (in either case). Spring Boot's `Started Application in 4.2 seconds` matches. A plain jar that prints nothing on start will sit at "Starting" in the panel while working perfectly. Print a line such as `Listening on 0.0.0.0:<port>` once your server is accepting connections and the status is right.

#### Choosing Java 17, 21 or 25

The Java line offers three images: Java 17, Java 21 and Java 25, all long-term-support releases. You pick one in the Docker Image list on the Startup tab, or with the runtime version setting on the GitHub tab if the server deploys from a repository - changing it there rebuilds the server on the new image.

The rule is simple: the runtime must be the same major version as your compile target or newer. A jar compiled for 17 runs on 21 and 25 unchanged. A jar compiled for 21 refuses to start on 17 with an `UnsupportedClassVersionError`, and the error gives you the class file version rather than the Java version:

| Class file version | Java release | Typical cause |
|---|---|---|
| 61.0 | Java 17 | Built with `--release 17` |
| 65.0 | Java 21 | Built on a JDK 21 without a release flag |
| 69.0 | Java 25 | Built on a JDK 25 without a release flag |

Pin the target in the build rather than trusting whatever JDK happens to be on the build machine. In Maven that is `<maven.compiler.release>21</maven.compiler.release>` in `<properties>`; in Gradle it is a toolchain:

build.gradle.kts:

```text
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}
```

Which to choose: Spring Boot 3 needs 17 or newer, and so do current Quarkus and Micronaut releases. If your dependencies are current, 21 is the sensible default - virtual threads are stable there, and it has years of support left. Take 25 if you want the newest LTS and your libraries have caught up; run your tests on it first, because agent-based tools and older bytecode libraries are what usually break on a new release. Keep 17 for code you cannot move, such as a Spring Boot 2.7 service, and treat that as a migration you owe yourself.

#### Building a jar that runs on its own

"Runnable" means two things: the manifest names a `Main-Class`, and every dependency is inside the jar or beside it. A jar built by a plain `mvn package` has neither, and fails with `no main manifest attribute, in app.jar` or with `NoClassDefFoundError` on the first library class.

For Spring Boot, the Boot plugin does both. `./mvnw package` or `./gradlew bootJar` produces an executable fat jar with an embedded web server. Give it a fixed name so the server's `JARFILE` never needs to change between versions:

pom.xml:

```xml
<build>
  <finalName>app</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>
```

That gives `target/app.jar`. Maven also leaves `target/app.jar.original`, the thin jar before repackaging; ignore it. In Gradle, `bootJar` writes to `build/libs/`, and the `jar` task writes a second, plain jar with a `-plain` suffix next to it. Upload the one without the suffix, or set `archiveFileName = "app.jar"` on the `bootJar` task.

Other frameworks have their own route to the same result:

- **Quarkus** builds a "fast jar" by default: `target/quarkus-app/quarkus-run.jar` plus a `lib` directory beside it. That runs fine if you upload the whole `quarkus-app` folder and set `JARFILE` to `quarkus-app/quarkus-run.jar`. If you want one file, set `quarkus.package.jar.type=uber-jar` (older 3.x releases used `quarkus.package.type`) and you get a single `*-runner.jar`.
- **Ktor** with its Gradle plugin has a `buildFatJar` task that writes an `-all.jar` to `build/libs/`.
- **Micronaut** projects generated by Micronaut Launch include the Shadow plugin for Gradle, so `./gradlew shadowJar` produces an `-all.jar`; on Maven, `mvn package` produces a runnable jar with dependencies.
- **Plain Java**, a JDA Discord bot, a Javalin service: use the Maven Shade plugin or the Gradle Shadow plugin, and set the main class in its configuration.

Check the result before it leaves your machine. `java -jar target/app.jar` should start without anything else on the classpath, and `unzip -p target/app.jar META-INF/MANIFEST.MF` shows the `Main-Class` line. If it starts locally with a bare `java -jar` on the same major version you picked for the server, it will start there.

#### The port: SERVER_PORT and server.port

Your plan comes with an allocation - an address and a port - and the application must listen on that port, on all interfaces. Not `8080`, and not `localhost`. A service on the wrong port starts cleanly, logs that it is ready, and answers nobody.

The port reaches your code twice. It is in the environment as `SERVER_PORT`, and the start command passes it as the system property `server.port`. That second form is why Spring Boot needs no configuration at all: Java system properties outrank `application.properties` in Spring's property order, so whatever `server.port` you set in the jar is overridden by the real one. That is useful, and occasionally surprising if you expected your own value to win.

Every other framework reads the port its own way:

| Framework | Where the port goes |
|---|---|
| Spring Boot | Nothing to do, `server.port` is set |
| Quarkus | `quarkus.http.port=${SERVER_PORT:8080}` |
| Micronaut | `micronaut.server.port: ${SERVER_PORT:8080}` |
| Ktor | `System.getenv("SERVER_PORT")` |
| Plain Java | `System.getenv("SERVER_PORT")` |

Quarkus also needs `quarkus.http.host=0.0.0.0` if you have changed it, and a hand-written server must bind every interface rather than the loopback address. The JDK's own HTTP server is enough for a health page or a small API, and shows the whole pattern:

Main.java:

```text
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;

public class Main {
    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(System.getenv().getOrDefault("SERVER_PORT", "8080"));
        HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0);
        server.createContext("/", exchange -> {
            byte[] body = "ok\n".getBytes();
            exchange.sendResponseHeaders(200, body.length);
            exchange.getResponseBody().write(body);
            exchange.close();
        });
        server.start();
        System.out.println("Listening on 0.0.0.0:" + port);
    }
}
```

A Discord bot built on JDA, or any worker that only makes outbound connections, does not need to listen at all. It still needs a start line the panel recognises, so print `Ready` once the bot has connected.

For a domain and HTTPS, put the proxy slot in front of the app rather than terminating TLS in the JVM. App plans include a reverse-proxy slot: point an A record at the address shown and the certificate is issued and renewed automatically. The app keeps speaking plain HTTP on its port, and the real client address arrives in `X-Forwarded-For`. In Spring Boot, `server.forward-headers-strategy=framework` makes `request.getRemoteAddr()` and generated URLs honour it. [Your domain and its certificate](https://www.renode.space/blog/your-domain-and-its-certificate) covers the DNS side.

#### Memory: the heap, the rest of the JVM and the limit

This is where most Java deployments on small plans go wrong, and where knowing the start command pays off.

The container has a hard memory limit equal to the plan. The JVM's footprint is the heap plus everything that is not the heap: metaspace for loaded classes, the JIT code cache, one stack per thread, direct byte buffers used by Netty and NIO, and the garbage collector's own structures. A Spring Boot web app commonly carries a couple of hundred megabytes of that before it has served a request.

`-XX:MaxRAMPercentage=80.0` lets the heap grow to 80% of the container limit. The JVM does not take it all at once - the heap starts smaller and grows when the application allocates faster than the collector reclaims - but under load it is allowed to reach the ceiling, and the non-heap memory has to fit in what is left:

| Plan memory | Heap ceiling | Left for everything else |
|---|---|---|
| 1 GB | about 820 MB | about 205 MB |
| 2 GB | about 1.6 GB | about 410 MB |
| 4 GB | about 3.3 GB | about 820 MB |
| 8 GB | about 6.6 GB | about 1.6 GB |

On 2 GB and above, that is comfortable for a typical service. On 1 GB it is tight for Spring Boot: 205 MB for metaspace, code cache and 200 Tomcat threads leaves little room, and a heap that fills under a burst can push the total over the line. A small Javalin API, a JDA bot or a Quarkus service fits on 1 GB without fuss. A Spring Boot app with JPA is happier on 2 GB.

What happens at the limit matters. On RE:NODE the kernel stops the container and it restarts clean instead of swapping. So there are two different failures, and they need opposite fixes:

- **`java.lang.OutOfMemoryError: Java heap space` in the console.** The heap ceiling was reached and the JVM said so. The data does not fit, or something leaks. A bigger plan or less held in memory is the answer.
- **A restart with no Java error at all.** The whole process crossed the container limit and was stopped from outside; the JVM never had a chance to log anything. The heap was not the problem on its own - non-heap memory was.

Because the command line is fixed and variables beginning with `JAVA`, `JDK_` or `JVM` cannot be set from the panel, you cannot lower the heap with `JAVA_TOOL_OPTIONS`. What you can control is the non-heap side, which is usually where 1 GB plans fail:

application.properties:

```properties
# Fewer request threads: each one has its own stack
server.tomcat.threads.max=50
# Create beans when first used instead of all at start
spring.main.lazy-initialization=true
# Do not keep JPA sessions open through the view layer
spring.jpa.open-in-view=false
```

Lazy initialisation lowers startup memory and time at the cost of slower first requests and errors that appear later instead of at boot; test before relying on it. On Java 21 and 25, `spring.threads.virtual.enabled=true` swaps platform request threads for virtual threads, which cuts stack memory sharply for I/O-bound services. Beyond that, trim dependencies you do not use - every starter adds classes to metaspace. If you have measured and still need a different heap setting, ask us in a ticket rather than fighting the limit; and if the app genuinely needs more memory, the honest answer is the next plan. [When to upgrade your plan](https://www.renode.space/blog/when-to-upgrade-your-plan) covers reading the graphs before you do.

Restarts are watched. A crash watcher polls every two minutes; restarts you asked for are not counted, but three unplanned ones in an hour put a warning on the server page and open a ticket, and six suspend the server. A jar that is killed at the limit every few minutes is exactly what that is looking for, so fix sizing before leaving it running. The console graphs memory against the limit; watch it through a day of real traffic. The general background is in [Linux swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer).

> [!WARNING] A container stopped at its memory limit leaves no stack trace. If the server restarts and the last lines in the console are ordinary log output, suspect total memory, not a bug in your code.

#### Environment variables and secrets

Configuration that changes between environments - database URLs, API keys, a profile name - belongs in the environment, not in the jar. Spring Boot's relaxed binding turns `SPRING_DATASOURCE_URL` into `spring.datasource.url`, `SPRING_PROFILES_ACTIVE` into `spring.profiles.active`, and so on, so most settings need no code.

On RE:NODE, your own variables are added on the server's Variables tab, under "Your variables": a name and a value, set in the container on the next start, up to 25 per server. Names are uppercase letters, digits and underscores, starting with a letter. Names the panel, the daemon or a language runtime already own are refused, and the list is wider than you might guess: anything starting `SERVER_`, `JAVA`, `JDK_`, `JVM`, `APP_`, `DB_`, `DATABASE_`, `PG`, `POSTGRES`, `REDIS` or `AWS_`, plus names such as `PATH` and `CLASSPATH`. So `DB_PASSWORD` will not save, while `SPRING_DATASOURCE_PASSWORD` or `ORDERS_DB_PASSWORD` will.

Variables tab:

```env
SPRING_PROFILES_ACTIVE=prod
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:port/appdb
SPRING_DATASOURCE_USERNAME=appuser
SPRING_DATASOURCE_PASSWORD=change-me
DISCORD_BOT_TOKEN=change-me
```

The host and port above are placeholders - use the ones your database server shows. Outside Spring, read variables with `System.getenv("NAME")` and fail loudly at start if a required one is missing; a clear error in the first second beats a `NullPointerException` an hour later.

If you prefer a file, Spring Boot also reads an `application.properties` or `application.yml` placed next to the jar, or in a `config/` folder beside it, and those outrank the copy packed inside the jar. Keep such a file on the server, never in the repository. The broader habits - what counts as a secret, rotation, keeping `.env` out of Git - are in [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets).

App plans include two database slots created in the panel, with a generated host, user and password, which suits a small service. For PostgreSQL, MongoDB or MySQL on their own server, [database hosting](https://www.renode.space/database-hosting) gives each engine its own plan and credentials.

#### Deploying from GitHub without building on the server

Uploading the jar by hand through the file manager or SFTP is fine for a first deploy. For anything you update often, connect GitHub.

The GitHub tab links a repository through a GitHub App with short-lived tokens, so private repositories work. You pick a production branch and a folder for the code, and two switches: pull the latest commit on every start, and deploy on push, which restarts a running server and starts a stopped one. Each deploy is recorded.

The catch is the one from the start command: the server runs a jar, it does not build one. A repository of Java source on the production branch gives the server nothing to run. The clean pattern is to let GitHub Actions build the jar and publish it to a branch that holds nothing but the artefact, and point the server at that branch.

```diagram
title: Source on main, jar on deploy, server runs the jar
dev = Your push | main branch
ci = GitHub Actions | mvnw package
branch = deploy branch | app.jar only
panel = RE:NODE panel | deploy on push
jvm = Java server | java -jar
dev -> ci : on push
ci -> branch : force-push
branch -> panel : webhook
panel -> jvm : restart
```

.github/workflows/build-jar.yml:

```yaml
name: Build jar
on:
  push:
    branches: [main]
permissions:
  contents: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: ./mvnw -B -DskipTests package
      - name: Publish the jar to the deploy branch
        run: |
          mkdir out && cp target/app.jar out/app.jar
          cd out
          git init -b deploy
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add app.jar
          git commit -m "Build ${GITHUB_SHA::7}"
          git push --force \
            "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" \
            deploy
```

Run your tests in a step before the package if you want a failing test to stop the deploy; `-DskipTests` here is only to keep the example short. The force-push of a fresh one-commit branch is deliberate: a jar committed on top of the last one on every build makes the repository grow by the size of the jar each time, and nobody needs that history - the source history is on `main`.

On the server, set the production branch to `deploy`, turn on deploy on push, and leave `JARFILE` at `app.jar`. Set the build's Java version and the server's image to the same major version. One caution about the folder setting: whatever is in the code folder is replaced by the repository on every start. If your app writes uploads, an H2 or SQLite file, or logs, have it write outside that folder - or put the code in a subfolder such as `release` and set `JARFILE` to `release/app.jar`, so the server root stays yours.

The GitHub tab also has a pre-flight check that reads the repository before you link it. Its runtime checks are written for Node.js, Python, Go, Bun and Deno rather than Java, but it still reports a committed `.env` by name without opening it, which is worth having on any repository. More on it in [the pre-flight check](https://www.renode.space/blog/pre-flight-check-catch-broken-deploys), and the panel side of deploys is in the [deploys guide](https://www.renode.space/guides#deploys).

#### Logs: the console, files and disk

The console shows the process's standard output, unfiltered and live, with a command line, power buttons and memory and CPU graphs against the limits. For most services that is the log: Spring Boot, Quarkus and Logback all log to the console by default. Spring Boot's coloured output and the console's handling of it are both fine; if colour codes clutter a copied log, `spring.output.ansi.enabled=never` turns them off.

The console is a live view, not an archive. For anything you need to read after a restart, write to a file as well, and cap it, because the disk limit is real: a server that goes over its disk limit while running is stopped and locked until space is freed. Spring Boot's Logback rotation needs four properties:

application.properties:

```properties
logging.file.name=logs/app.log
logging.logback.rollingpolicy.max-file-size=10MB
logging.logback.rollingpolicy.max-history=7
logging.logback.rollingpolicy.total-size-cap=200MB
```

`total-size-cap` is the one that protects the disk; without it, `max-history` keeps seven days however large they are. The relative path is resolved from the server's folder, which is the working directory. Read the files in the file manager, or fetch them over SFTP.

What to log is a separate question from where. Log one line per request at most, log errors with their stack trace once, and do not log request bodies or headers that may carry tokens. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) goes further.

Stopping is part of the same picture. The stop button sends `Ctrl+C`, which the JVM treats as SIGINT and answers by running shutdown hooks. Spring Boot uses that moment for a graceful shutdown: with `server.shutdown=graceful` it stops taking new requests and lets in-flight ones finish, up to `spring.lifecycle.timeout-per-shutdown-phase` (30 seconds by default). That is what makes a deploy-on-push restart polite to the requests in flight. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) has the detail.

#### Troubleshooting a Java server that will not start

The errors people actually see, and what each one means:

- **`Error: Unable to access jarfile app.jar`.** The file named in `JARFILE` is not there. Check the name's case, the folder, and whether a GitHub deploy put the code in a subfolder.
- **`no main manifest attribute, in app.jar`.** The jar is a library jar, not an executable one. Build it with the Boot, Shade or Shadow plugin, or upload the right file - in Gradle, not the `-plain` one.
- **`UnsupportedClassVersionError ... class file version 65.0`.** Compiled for a newer Java than the server runs. Change the image on the Startup tab or set the build's release to match.
- **`NoClassDefFoundError` or `ClassNotFoundException` for a library class.** The dependencies are not in the jar. Same fix as the manifest error.
- **Starts, logs that it is ready, but the address does not answer.** It is listening on another port or on `localhost`. Check the startup log line for the port and compare it with the allocation.
- **The panel shows "Starting" for ever.** The app works but never printed one of the start words. Print `Listening` or `Ready` once it is up.
- **Restarts every few minutes with no error.** The process hit the memory limit. See the memory section.
- **`APPLICATION FAILED TO START` from Spring Boot.** Read the "Description" and "Action" lines under it; they are usually right. A missing datasource URL is the most common, which means a variable did not save or has a typo.

#### FAQ

##### Can the server build my project with Maven or Gradle?

No. The Java server runs a finished jar and has no build step. Build on your machine or in CI - the GitHub Actions workflow above is the usual way - and deploy only the jar.

##### Can I change the -Xmx or other JVM flags?

Not from the panel. The start command is fixed and sets the heap ceiling to 80% of the plan's memory, and `JAVA_TOOL_OPTIONS` is one of the reserved variable names. You can reduce non-heap memory from inside the application, and if you have a measured need for different flags, open a ticket.

##### How much memory does a small Java service need?

A plain Java or Javalin API, a Quarkus service or a JDA bot runs well on 1 GB. A Spring Boot web app with JPA is more comfortable on 2 GB, because the JVM's non-heap memory takes a larger share of a small plan.

##### Do I need a separate port for Spring Boot Actuator?

Usually not. Keeping Actuator on the main port and exposing only the endpoints you need, such as `health`, is simpler than managing a second port, and `management.endpoints.web.exposure.include=health` is the line that does it. Anything more sensitive, such as `env` or `heapdump`, should stay unexposed on a public address.

##### Can I run a WAR file?

Not directly - there is no servlet container installed. Spring Boot can package the same application as an executable jar with an embedded Tomcat, which is the normal route. An old WAR-only application needs repackaging first.

##### Does the server stay on the Java version I picked?

Yes. The version is the image the server runs, chosen on the Startup tab or the GitHub tab, and it does not change unless you change it.

Java servers on RE:NODE run any jar on Java 17, 21 or 25, with the port passed to your app, the heap sized to the plan, deploys from GitHub on push, a reverse-proxy slot with automatic certificates, and backups on every plan.

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

---

### Deploy a Rust app with Cargo: build, port, env and GitHub

Published 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/deploy-a-rust-app-with-cargo

To deploy a Rust web service on a Cargo app server you need four things right: a repository with `Cargo.toml` and a committed `Cargo.lock` at its root, a program that listens on `0.0.0.0` and the port in the `SERVER_PORT` environment variable, a line printed to standard output once it is listening (the panel uses it to know the server has started), and a plan with enough memory to compile your dependency tree, which is a very different number from what the finished binary needs. Get those four right and the rest is a GitHub connection and a habit of reading the console. This is the Rust language, by the way - if you came here for the survival game, you want the [Rust dedicated server guide](https://www.renode.space/blog/rust-dedicated-server-setup) instead.

This post walks through it in the order you will hit it: what happens when the server starts, preparing the repository, the port and the start-detection line, how long a build takes and how much RAM it eats on each tier, configuration and secrets, deploying from GitHub, and reading the logs when it goes wrong. For deeper tuning of the service itself - tokio worker threads, allocator behaviour, graceful shutdown, connection pools - we have a separate post on [deploying a Rust web service with axum](https://www.renode.space/blog/deploy-a-rust-web-service). This one is about getting from a repository to a running, reachable process.

#### What happens when a Cargo server starts

A Rust (Cargo) server on RE:NODE is a container with the Rust toolchain in it and your project in its files. It does not run a prebuilt image of your app. On every start it builds and runs your code with cargo, which has a consequence worth absorbing early: a start is a compile. A restart is a compile. A deploy is a compile. Cargo is good at not redoing work it has already done, so most of those compiles are short, but the first one never is.

If you connect a GitHub repository, the order on each start is: fetch the branch you chose, then build, then run. The diagram is the whole lifecycle.

```diagram
title: One start of a Cargo app server
gh = GitHub repo | your branch
fetch = Fetch on start | pull or deploy on push
build = cargo build | uses target/ cache
bin = Your binary | listens on SERVER_PORT
proxy = Proxy slot | HTTPS for your domain
users = Visitors
gh -> fetch
fetch -> build
build -> bin
users -> proxy : HTTPS
proxy -> bin : HTTP
```

A brand-new server is not empty. Until you deploy, it holds a tiny standard-library Rust project - a `Cargo.toml` and a `src/main.rs` that serves a "what to do next" page on its address. It is there so the first start shows something working rather than a crash, and it only ever gets written into an empty server; it never overwrites files of yours. Open the address once to confirm the server answers, then replace the project with your own.

The startup command itself is visible on the Startup tab. Read it once, so you know whether it builds in release mode and which binary it runs - the sections below assume a release build, which is the only kind worth deploying.

#### Prepare the repository

Cargo needs very little, but it needs it in the right place. The server builds from the root of what it fetched, so `Cargo.toml` must be at the top of the repository, not in a `backend/` folder. If your service lives in a workspace, put the workspace `Cargo.toml` at the root and make sure the binary you want is the one that runs by default.

The checklist:

1. **Commit `Cargo.lock`.** For a binary it belongs in the repository. Without it every build resolves dependencies afresh, and two deploys of the same commit can produce different programs.
2. **Ignore `target/`.** It is the build cache, often gigabytes, and it is machine-specific. It goes in `.gitignore`, never in a commit.
3. **Pick the default binary.** If the package has more than one binary (`src/bin/*.rs` plus `src/main.rs`), `cargo run` refuses to guess. Set `default-run` in `[package]` so it does not have to.
4. **Never commit `.env`.** Secrets go on the server, not in Git. More on that below.
5. **Pin the toolchain if you depend on a recent feature.** A `rust-version` in `Cargo.toml` makes an old compiler fail with a clear message instead of a page of confusing errors.

A minimal `Cargo.toml` for an axum service, with a release profile tuned for building on a small server rather than for the last few percent of speed:

Cargo.toml:

```toml
[package]
name = "api"
version = "0.1.0"
edition = "2021"
default-run = "api"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[profile.release]
strip = "symbols"
debug = false
```

The version numbers are examples; use whatever `cargo add` gives you on the day. Note what is not in that profile: no `lto = true`, no `codegen-units = 1`. Both make the binary a little faster and smaller and the build considerably slower and hungrier. On a 1 GB plan they can turn a build that finishes into one that does not. Add them later, once you know the build fits.

And the `.gitignore` that every Rust repository should start with:

.gitignore:

```text
/target
.env
```

#### Bind to the allocated port

Your plan comes with one allocated port. The panel puts it in the environment as `SERVER_PORT`, and your program has to listen on exactly that port on `0.0.0.0`. Two mistakes cover most "it runs but I cannot reach it" reports:

- **Listening on `127.0.0.1` or `localhost`.** Inside a container that means "only reachable from inside this container". The log says it is listening, and nothing outside can connect.
- **Hard-coding a port.** Tutorials love `3000` and `8080`. Your allocated port is neither, unless by coincidence.

Read the port from the environment and fail loudly if it is missing, rather than quietly falling back to a port nobody forwards:

src/main.rs (axum):

```text
use axum::{routing::get, Router};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let port: u16 = std::env::var("SERVER_PORT")
        .expect("SERVER_PORT is not set")
        .parse()
        .expect("SERVER_PORT is not a number");

    let app = Router::new().route("/health", get(|| async { "ok" }));
    let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();

    println!("listening on 0.0.0.0:{port}");
    axum::serve(listener, app).await.unwrap();
}
```

The actix-web equivalent is the same idea in a different builder:

src/main.rs (actix-web):

```text
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let port: u16 = std::env::var("SERVER_PORT").expect("SERVER_PORT").parse().unwrap();

    let server = HttpServer::new(|| App::new().route("/health", web::get().to(health)))
        .workers(2)
        .bind(("0.0.0.0", port))?;

    println!("listening on 0.0.0.0:{port}");
    server.run().await
}
```

The `.workers(2)` matters on a small plan. actix-web starts one worker per logical CPU it can see by default, and a container limited to half a core may still see the whole machine's core count. Two workers is plenty on the lower tiers.

If you want the same code to run on your laptop, keep a fallback for local development only, behind an explicit variable or a `cfg(debug_assertions)` check, so a release build on the server never silently binds the wrong port.

#### The start-detection line

This is the step that catches almost everyone once. The panel decides that a server has finished starting by watching the console for a particular piece of text. Until it sees that text, the server shows as "Starting", however well your service is actually running. On a Cargo server you set that text yourself on the Startup tab, and it must be a line your program really prints.

The simplest arrangement is the one in the examples above: print one plain, stable line after the listener is bound, and use a fragment of it as the start-detection text.

```text
listening on 0.0.0.0:
```

A few details make this reliable:

- **Print it after `bind` succeeds**, not at the top of `main`. Then "started" means "accepting connections", which is what you want the panel to report.
- **Use `println!`, not only `tracing`.** Rust's standard output is line-buffered, so a `println!` reaches the console the moment it runs. A `tracing` line works too, but its format depends on your subscriber config and an innocent change to the format (timestamps, colours, JSON output) can stop the text matching.
- **Do not include the port number in the detection text** unless you are sure it will never change. The fragment `listening on 0.0.0.0:` survives a port change; the full line does not.
- **Keep it out of build output.** Cargo prints `Compiling` and `Finished` lines during the build. Never use text that cargo itself prints, or the panel will call the server started before your program has run.

> [!TIP] If a server sits at "Starting" while the console shows your service answering requests, the start-detection text is the cause in nearly every case. Copy a fragment of a line you can see in the console into the Startup tab and restart.

#### Build time and memory on each plan

Here is the uncomfortable part of Rust hosting. The program you deploy idles in tens of megabytes. The compiler that builds it can need well over a gigabyte, for minutes, with the peak at the final link. The plan has to fit the build, not just the binary.

Rust (Cargo) plans on RE:NODE start at $4 a month and run in five tiers:

| Plan | RAM / disk / CPU | Building on the server |
|---|---|---|
| Starter | 1 GB / 5 GB / 0.5 vCPU | Small services, one job at a time |
| Standard | 2 GB / 10 GB / 1 vCPU | A typical axum or actix service |
| Advanced | 4 GB / 20 GB / 1.5 vCPU | Large trees, sqlx, some LTO |
| Pro | 6 GB / 35 GB / 2 vCPU | Heavy trees, LTO without worry |
| Premium | 8 GB / 50 GB / 3 vCPU | Workspaces, several binaries |

The right-hand column is our rule of thumb, not a guarantee. Build cost depends on how many crates are in `Cargo.lock`, how macro-heavy they are (serde derive, sqlx, anything with procedural macros), and whether the C dependencies need compiling. As a rough guide from experience: a small service with a few dozen crates does its first release build in a few minutes on one core; a typical web service with two or three hundred crates takes longer, and on half a core it can be ten minutes or more. Later builds that only touch your own code are much faster, because the compiled dependencies sit in `target/` and are reused.

What happens when the build is too big for the plan matters, because it does not look like an error. On RE:NODE, a container that reaches its memory limit is stopped by the kernel and restarted clean rather than being allowed to swap. A build that runs out of memory therefore shows up as the console stopping mid-`Compiling` and the server starting again, often into the same wall. If you see that pattern, it is memory, and there are three fixes in order of preference:

1. **Build one crate at a time.** Cargo compiles crates in parallel by default, and each parallel job holds its own memory. Setting jobs to one cuts the peak sharply at the price of wall-clock time.
2. **Take LTO and `codegen-units = 1` out of the release profile.** They are the most expensive settings in the link step.
3. **Move up a tier.** If the service needs three hundred crates, it needs a plan that can compile them. Changing plan does not rebuild the server.

The first fix can live in the repository, so it travels with the code and applies to every build on the server:

.cargo/config.toml:

```toml
[build]
jobs = 1
```

Disk deserves the same attention. A `target/` directory for a middling web service is commonly one to three gigabytes, and if both debug and release artefacts end up in it, more. The Starter plan's 5 GB disk can fill with build cache alone. If you ever ran a debug build on the server, deleting `target/debug` in the file manager is an easy gigabyte. Do not delete all of `target/` as a habit - that throws away the dependency cache and the next start compiles everything again.

> [!WARNING] A build that is killed at the memory limit restarts the server, and the panel's crash watcher counts restarts. A server that keeps falling over mid-build gets a warning and an automatic ticket after three restarts in an hour. Fix the build size instead of letting it loop.

There is a legitimate alternative for very small plans: build the release binary in CI or on your own machine for `x86_64-unknown-linux-gnu` (or `-musl` for a static binary), upload it over SFTP, and run that. You give up the convenience of building from source on every start, and it only works if the startup command runs your binary rather than cargo, so check the Startup tab before you try it.

#### Environment variables and secrets

Configuration goes in environment variables, read once at start-up, and the program refuses to start when one is missing. That way a forgotten database URL stops the deploy at the console instead of failing at three in the morning on the first request that needs it.

There are two places to put values on a Cargo server:

- **The Startup tab.** It shows the server's variables, including `SERVER_PORT`. Use it for the variables the panel exposes there.
- **A `.env` file in the server's files.** Create it with the file manager or upload it over SFTP, and load it with the `dotenvy` crate at the top of `main`. It never goes in Git.

.env:

```env
DATABASE_URL=postgres://app_user:generated-password@db-host:5432/app
RUST_LOG=info,tower_http=info,sqlx=warn
RUST_BACKTRACE=1
APP_BASE_URL=https://api.example.com
```

src/main.rs:

```text
fn main() {
    // Loads .env if it exists; real environment variables win.
    let _ = dotenvy::dotenv();
    // ...
}
```

`dotenvy::dotenv()` does not overwrite variables that are already set in the environment, so `SERVER_PORT` from the panel always wins over a stray value in the file. That is the behaviour you want.

If you connect the repository to GitHub, the pre-flight check on the GitHub tab reports a committed `.env` file by name without opening it. If it ever flags one, remove it from the repository and rotate every secret it contained, because the history still has it. The general rules - where secrets live, how to rotate them, what never to log - are in [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets).

App plans include database slots: create one in the panel and you get a generated host, user and password to put in `DATABASE_URL`. If your service uses sqlx's compile-time checked queries, the build needs either a live database or prepared query data: run `cargo sqlx prepare` locally, commit the `.sqlx` directory, and set `SQLX_OFFLINE=true` so the server build does not try to reach a database while compiling. A separate PostgreSQL server, if you outgrow a slot, is a [database hosting](https://www.renode.space/database-hosting#postgresql) plan.

#### Deploy from GitHub

Uploading source over SFTP works, but GitHub deploys are what make a Cargo server pleasant to live with. The panel connects through a GitHub App with short-lived tokens, so private repositories work without you pasting a personal access token anywhere. GitHub is the only provider supported.

On the server's GitHub tab you link a repository and a branch, and there are two switches:

- **Pull on every start.** Each start fetches the branch before building. A restart from the panel is then also an update.
- **Deploy on push.** A push to the branch restarts a running server, or starts a stopped one, which fetches and builds the new commit.

Every deploy leaves a record, so you can see which commit went out and when. Before you link, the pre-flight check reads the repository - a tree listing and a few small files, never a full clone - and lists what will break, worst first, with a fix to paste: a committed `.env`, binding the wrong host or port, and similar mistakes that would otherwise only show up after the first start. It checks the obvious failure modes; it does not compile your code, so a type error still surfaces in the console.

Two things are specific to Rust and worth planning for:

- **A deploy is a restart, and a restart is a build.** The old process stops, the new commit compiles, and only then does the new binary start listening. For a change to your own code that is usually seconds to a minute; for a change to `Cargo.lock` that bumps a widely used dependency it can be several minutes, during which the service is down. Batch dependency updates rather than pushing them one at a time, and push them when nobody is relying on the service.
- **A broken build means a server that does not come up.** If the new commit does not compile, there is no old binary serving in the meantime. Run `cargo build --release` and your tests locally or in CI before the push reaches the deploy branch, and consider deploying from a dedicated branch that only receives merges that passed.

For more on keeping that window small, [zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers what is and is not achievable with one process on one port, and [pre-flight checks that catch broken deploys](https://www.renode.space/blog/pre-flight-check-catch-broken-deploys) goes through the GitHub tab's check in detail.

#### Logs and the console

Everything your program writes to standard output and standard error appears in the panel console, live and unfiltered, next to graphs of memory, CPU and disk against the plan's limits. That console is the first place to look for every problem in this post: the build output, the start-detection line, panics and your own logs all land there.

For structured logging, `tracing` with `tracing-subscriber` writing to standard output is the standard choice, and the `RUST_LOG` variable sets what gets through:

```env
RUST_LOG=info,tower_http=debug,sqlx=warn,hyper=warn
```

The format is a default level, then per-crate overrides. `tower_http=debug` with a `TraceLayer` on the router logs every request with its status and latency, which is the most useful single line you can add when debugging. Turn it back to `info` when you are done; request logs at debug level on a busy service are noise.

Set `RUST_BACKTRACE=1`. It costs nothing until something panics, and then it turns "thread 'tokio-runtime-worker' panicked" into a stack you can act on. If you want line numbers in those backtraces, set `debug = "line-tables-only"` in the release profile; it makes the binary bigger, not the process hungrier.

Watch the memory graph during the first build and the first hour of traffic. The build peak tells you whether your plan is sized right. The running process should settle at a flat line well under the limit; a line that climbs steadily under constant load is worth investigating before it meets the ceiling. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) covers what to keep, and for how long, once the service is in steady state.

#### Domain and HTTPS

Serve plain HTTP on your allocated port and let the proxy slot handle TLS. App plans include one: point an A record for your domain at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window. Your service never touches a certificate file.

Behind the proxy, the client's real address is in the `X-Forwarded-For` header, not in the connection, so axum's `ConnectInfo` gives you the proxy rather than the visitor. If you rate-limit or log by IP, read the header. Anywhere you build absolute URLs - redirects, OAuth callbacks, links in emails - use a configured base URL such as `APP_BASE_URL` rather than guessing from the request. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the rest of the header chain, and [websockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) matters if you use `axum::extract::ws`.

#### Troubleshooting

**The server sits at "Starting" for ever.** The start-detection text on the Startup tab does not match anything your program prints. Copy a fragment of your listening line into it and restart.

**The console stops mid-`Compiling` and the server restarts.** The build hit the memory limit. Set `jobs = 1` in `.cargo/config.toml`, drop LTO from the release profile, or move up a tier.

**`error: could not find Cargo.toml`.** The project is in a subdirectory of the repository. Move it to the root, or put a workspace `Cargo.toml` there.

**`cargo run` could not determine which binary to run.** The package has several binaries. Add `default-run` to `[package]`.

**It is running but the address does not answer.** It is bound to `127.0.0.1` or to a hard-coded port. Bind `0.0.0.0` and read `SERVER_PORT`.

**`Address already in use`.** Something else in the same process tree already holds the port - often a second listener you added for metrics on the same port. One plan has one allocated port; serve metrics on a route of the main router instead.

**Every deploy takes as long as the first one.** Something is clearing `target/`, or `Cargo.lock` is not committed and dependencies resolve differently each time. Commit the lock file and leave the cache alone.

**`set DATABASE_URL to use query macros`.** sqlx is trying to check queries at compile time. Commit the `.sqlx` directory from `cargo sqlx prepare` and set `SQLX_OFFLINE=true`.

**The disk is full.** Usually `target/`. Delete `target/debug` if it exists, and check that you are not building debug and release side by side.

#### FAQ

##### Is this for the Rust game or the Rust programming language?

The language. Rust (Cargo) hosting runs programs written in Rust and built with cargo - web services, bots, workers. The survival game is a different product with its own setup, covered in our [Rust dedicated server guide](https://www.renode.space/blog/rust-dedicated-server-setup).

##### Which plan do I need for an axum service?

The service itself would run on the smallest plan. The build decides. A small service builds on 1 GB with one compile job at a time; a typical web service with a few hundred crates is far more comfortable on the 2 GB tier or above. Watch the memory graph during the first build and size from what it shows.

##### Why does a restart take so long?

Because every start builds the project with cargo before running it. Once dependencies are cached in `target/`, a restart with no code changes is mostly cargo confirming nothing needs rebuilding, and a change to your own code recompiles only your crate. A changed `Cargo.lock` is what makes it slow again.

##### Can I run something other than a web server?

Yes. A Discord or Telegram bot, a queue worker or a scheduled job runner all work the same way: the program runs, prints a start-detection line, and keeps running. It does not have to listen on the port if nothing needs to reach it, though a small health route is still useful for checking it from outside.

##### Do I have to deploy from GitHub?

No. You can upload source with the file manager or SFTP and restart, and the server builds whatever is in its files. GitHub deploys are the convenient path, and the only Git provider the panel connects to.

##### Can I use a nightly toolchain?

The server builds with the toolchain in its image, and we do not document a way to switch it to nightly. If you depend on a nightly feature, building elsewhere and uploading the binary is the practical route - check the startup command first to make sure it can run a prebuilt binary.

Rust (Cargo) servers start at $4 a month and build and run your project with cargo on every start, with GitHub deploys on start or on push, database and backup slots, and a proxy slot whose certificate is issued and renewed for you.

[See Rust (Cargo) plans](https://www.renode.space/app-hosting#rust-cargo)

---

### Deploy Symfony to production on FrankenPHP

Published 2026-10-16, updated 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/deploy-symfony-on-frankenphp

A Symfony deploy is the same short list every time: production mode on, the right environment variables in place, dependencies installed without the dev packages, a warm cache, a migrated database, a writable `var/` directory, and a decision about what runs your Messenger workers. FrankenPHP does not change that list. It changes the server underneath it: instead of nginx talking to PHP-FPM, one process, built on Caddy, serves HTTP and runs PHP. For a Symfony application that is mostly good news, and this guide covers the parts where it is not.

It is written for the Laravel / PHP app line on RE:NODE, which runs Laravel or Symfony on FrankenPHP with PHP 8.3, 8.4 or 8.5 and Composer installing dependencies on start. The Symfony half of every command below is the same on any host. The platform half - where the variables go, how the proxy slot reaches you, what the panel gives you - is marked as such, and anything the platform does not document is called out rather than guessed.

#### What changes with FrankenPHP

A classic PHP stack has three moving parts: a web server (nginx or Apache), PHP-FPM, and the glue between them. FrankenPHP collapses that into a single binary. It embeds the PHP interpreter in a Go web server built on Caddy, so there is one process to start, one log to read, and no `fastcgi_pass` line to get wrong.

That has three practical consequences for a Symfony app.

- **There is no nginx config to port.** The `try_files` and `fastcgi_split_path_info` blocks from the Symfony docs exist to route every request to `public/index.php`. Caddy has a front-controller idiom that does the same job, and a Symfony project served by FrankenPHP does not need your old server block.
- **Worker mode exists, and is optional.** In normal mode FrankenPHP behaves like PHP-FPM: each request boots the framework, handles the request and throws everything away. In worker mode the Symfony kernel boots once and stays in memory across requests, which can cut per-request time a lot. It needs the `runtime/frankenphp-symfony` package and carries the same warnings as any long-lived PHP process: static state and service state leak between requests unless the services are reset. Symfony resets most of its own, but your code is your problem. Treat worker mode as a tuning step after the app works, not a prerequisite for deploying it.
- **PHP version is a platform setting.** RE:NODE offers 8.3, 8.4 and 8.5 on this line. Symfony 7.4 needs PHP 8.2 or newer, and Symfony 8.0 needs PHP 8.4, so check the `require.php` constraint and the `symfony/*` versions in your `composer.json` before choosing. Take the newest PHP your whole dependency tree allows.

Everything else in this guide is ordinary Symfony. If you are weighing how much of it carries over from Laravel, [deploying Laravel to production](https://www.renode.space/blog/deploy-laravel-to-production) covers the framework side of that stack, and the two posts differ mostly in names: `APP_ENV=prod` versus `production`, `bin/console` versus `artisan`, Messenger versus the queue worker.

#### Production mode: APP_ENV, APP_DEBUG and APP_SECRET

Symfony decides almost everything from three variables. In production they are:

```env
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=replace-with-a-long-random-string
```

`APP_ENV=prod` selects the `prod` configuration and the `when@prod` blocks in `config/`. It also stops the framework recompiling the container and checking template modification times on every request. `APP_DEBUG=0` turns off the debug container and the exception page that prints file paths, service arguments and environment details. Running `APP_ENV=prod` with `APP_DEBUG=1` is possible and worth avoiding: you get production caching with development leakage.

`APP_SECRET` feeds CSRF tokens, signed URLs, the remember-me cookie and anything else Symfony signs. Generate it once and leave it alone, because changing it invalidates signed links and logs people out. A reasonable way to make one:

```bash
$ php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
```

The default environment, when nothing sets one, comes from the `APP_ENV` line in `.env`, and the recipe ships it as `dev`. That is the first thing to check when a freshly deployed app shows the Symfony profiler toolbar to the whole internet: no real variable overrode the file.

#### .env, .env.local and real environment variables

Symfony's Dotenv component loads files in a defined order, and understanding it removes most "why is it using the wrong database" tickets. The files, from lowest to highest priority:

| File | Committed? | Purpose |
| --- | --- | --- |
| `.env` | Yes | Defaults and documentation of every variable |
| `.env.local` | No | Machine-specific overrides, not read when `APP_ENV=test` |
| `.env.prod` | Yes | Defaults for the `prod` environment |
| `.env.prod.local` | No | Machine-specific `prod` overrides |

Above all of those sit **real environment variables**: values set by the process that starts PHP. Dotenv never overwrites a variable that already exists, so a real variable always wins over any file. That is the property you want in production. The files are a convenience for development; the server's own environment is the source of truth.

What follows from that:

- Commit `.env` with safe placeholders only. It documents the variable names and is the baseline.
- Do not put production secrets in `.env`, and never commit `.env.local`. Anything that reaches git has to be treated as leaked.
- On RE:NODE, set the production values as environment variables on the Startup tab of the server. They are real variables, so they override the files. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) goes through what belongs where and what not to log.
- If you prefer a file, create `.env.local` on the server through the file manager or SFTP. It works, and it sits in the project directory, so exclude it from any archive you share.

There are two Symfony-native alternatives worth knowing. The first is `composer dump-env prod`, which compiles the `.env` files into a single `.env.local.php` array that is loaded without parsing text on each boot. Run it on the machine that will serve the app, as part of the deploy, and remember it freezes the values: change a variable and you must dump again. The second is the secrets vault, `php bin/console secrets:set DATABASE_PASSWORD --env=prod`, which stores encrypted values in `config/secrets/prod/` that can be committed. Only one real variable is then needed on the server - the decryption key, `SYMFONY_DECRYPTION_SECRET` - and `php bin/console secrets:decrypt-to-local --env=prod` can turn the vault into a local file when you want one.

> [!WARNING]
> Never leave `.env.local.php` or `.env.local` from your laptop in the upload. A file generated with `APP_ENV=dev` and a local database URL will silently beat the committed `.env`, and the symptom is production talking to a database that does not exist on that machine.

#### Composer on start and the production install

On this app line Composer installs dependencies when the server starts, so a new `composer.lock` takes effect on the next restart. The Symfony-side rule is the same as anywhere: commit `composer.lock`, so the install resolves the exact versions you tested. Without a lock file, every start re-resolves the constraints and the version you run in production is whatever Packagist offered that morning.

The command a production install should be equivalent to is:

```bash
$ composer install --no-dev --optimize-autoloader --no-interaction
```

`--no-dev` skips `require-dev` packages: the profiler, the maker bundle, PHPUnit, debug tooling. `--optimize-autoloader` builds a class map so PHP does not search the filesystem on each autoload. In Symfony apps, `composer.json` also carries an `auto-scripts` section, which the recipe wires into `post-install-cmd` so that `cache:clear` and `assets:install public` run after every install. That is why the install and the cache step are entangled.

The classic failure follows from it. If `APP_ENV` is not `prod` when Composer finishes and dev packages are absent, the post-install `cache:clear` tries to build the `dev` container, finds `Symfony\Bundle\DebugBundle\DebugBundle` missing from `config/bundles.php`'s dev entries, and aborts. Set `APP_ENV=prod` in the environment before the install runs, not after, and the error does not appear. If you need dev packages on a staging server, keep them in place and set `APP_ENV=dev` there on purpose.

The platform does not document which flags the start-up install uses, so read the console output of a first start rather than assuming. The console shows the install output live, unfiltered. [Reading the console](https://www.renode.space/blog/reading-the-console) explains the history, the folded noise and the memory graph, which you will want open the first time.

Front-end assets are the other build step. With AssetMapper, `php bin/console asset-map:compile` writes the fingerprinted files to `public/assets/`. With Webpack Encore or Vite, build on your own machine or in CI and upload the output: installing Node and compiling a large bundle on a 1 GB plan is a sensible way to hit the memory limit.

#### Cache clear and warmup

The production cache lives in `var/cache/prod/`. It contains the compiled service container, the routing table, compiled Twig templates, validator and serializer metadata, and the Doctrine proxy classes. All of it is generated, none of it belongs in git, and a stale copy is the cause of a surprising share of "I deployed but nothing changed" reports.

Two commands matter:

```bash
$ php bin/console cache:clear
$ php bin/console cache:warmup
```

In the `prod` environment `cache:clear` rebuilds the cache and warms it as part of the same run, so one command is normally enough; `cache:warmup` exists to warm without clearing, and `--no-warmup` on `cache:clear` skips the warm-up. The reason to warm in the deploy rather than leave it to the first request is simple: the first request after a cold cache compiles the container and can take many seconds and several hundred megabytes of memory. On a small plan that spike can reach the limit by itself.

Two habits keep this uneventful:

1. **Build the cache where it will be served.** The compiled container contains absolute paths. A cache built on your laptop and uploaded points at directories that do not exist on the server.
2. **Do not mix users.** If a command run as one user creates `var/cache/prod/` and the web process runs as another, the second cannot write to it. The panel console and the server process share the container's user, which avoids the problem; an SFTP session as a different user can recreate it, so clear the cache from the console after uploading.

Also be aware that cached configuration is the reason a changed environment variable seems to do nothing. Symfony resolves `%env()%` values at runtime, so ordinary variables are not baked into the cache, but anything compiled from the container, like a parameter you derived in `services.yaml`, is. When in doubt, clear.

#### Doctrine migrations

Schema changes ship as migration classes in `migrations/`, and the production command is:

```bash
$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
```

`--no-interaction` stops the confirmation prompt that would hang a non-interactive deploy. `--allow-no-migration` makes the command exit successfully when there is nothing to run, which keeps a deploy script from failing on the common case of a release without schema changes. Before the first deploy, and any time you are unsure, `doctrine:migrations:status` prints the configured database, the current version and how many migrations are pending, and `doctrine:schema:validate` compares your mapping against the live schema.

Never use `doctrine:schema:update --force` in production. It diffs and applies in one step, with nothing reviewed and nothing recorded. Generate migrations in development with `php bin/console doctrine:migrations:diff`, read the SQL, commit it, and let production only run committed migrations.

The `DATABASE_URL` variable carries the connection. The scheme follows the engine and the host, port, user and password come from wherever the database was created:

```env
DATABASE_URL="postgresql://app:secret@db.example.net:5432/app?serverVersion=16&charset=utf8"
```

For MySQL the scheme is `mysql://` and the `serverVersion` is the real server version, not a guess: Doctrine uses it to pick SQL dialect features, and a wrong value produces migrations that fail in odd ways. On RE:NODE an app server has database slots created in the panel with a generated host, user and password, and the separate database line sells PostgreSQL, MySQL 8.4 LTS and others on their own host and port. Take the values from the panel, and URL-encode the password if it contains characters like `@`, `/` or `:`, otherwise Doctrine parses the URL into nonsense. [Database backups and restores](https://www.renode.space/blog/database-backups-and-restores) explains the backup half of the story.

Order matters. A release that ships code needing a new column before the migration has run gives every visitor a 500 for the length of the gap. For anything beyond a small site, write backward-compatible migrations: add the column nullable, deploy code that tolerates it, backfill, then tighten in a later release. [Migrations without downtime](https://www.renode.space/blog/migrations-without-downtime) goes through the pattern. Take a backup before any migration that drops or renames something; on RE:NODE the Backups tab does that on demand and restores with a button.

#### Messenger workers, the transport and the scheduler

Anything slow in a request - mail, image processing, calls to third parties - belongs on a Messenger transport, and a transport needs a consumer process that outlives the request. First choose the transport in `.env`:

```env
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
```

The Doctrine transport stores messages in a database table and needs nothing extra. Set `auto_setup=0` in production and create the table once with `php bin/console messenger:setup-transports`, so the app never tries to alter the schema at runtime. For more throughput, a Valkey-backed transport (`redis://` in the DSN, since Valkey speaks the Redis protocol) is the next step - see [job queues with Valkey](https://www.renode.space/blog/job-queues-with-valkey).

The consumer is a long-running command:

```bash
$ php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
```

The two limits are the important part. `--time-limit=3600` makes the worker exit after an hour, and `--memory-limit=128M` makes it exit when it grows past that, which defends you against a slow leak in code you do not control. A supervisor is expected to start it again, which is the part a container deployment has to answer for itself.

**A RE:NODE server is one container running one process.** The panel's tabs are built around that, so the honest design is to treat the consumer as its own server: a second app server on the same line, same repository, same environment variables, same database, started with a command that runs the worker instead of the web server. Check the Startup tab on your plan for whether the start command can be changed, because the platform copy confirms environment variables there and does not describe command editing. If it cannot, the alternatives are a messenger transport that is consumed by a small scheduled console task, or running with the `sync` transport for low volume while you decide. Do not run a worker as a background process inside the web server's own console session and expect it to survive a restart.

After every deploy the consumer holds the old code in memory. Tell it to stop with `php bin/console messenger:stop-workers`, which sets a flag in the cache pool; each worker finishes its current message and exits, and the restart brings it back on the new code. Failed messages go to a failure transport if you configure one, and then:

```bash
$ php bin/console messenger:failed:show
$ php bin/console messenger:failed:retry
```

The same long-running machinery runs the Symfony Scheduler: define the recurring messages with `#[AsPeriodicTask]` or a schedule provider, and consume the `scheduler_default` transport with `messenger:consume scheduler_default`. It replaces a crontab of console commands with one worker. If you would rather use plain cron expressions, [cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) covers the five fields, and [background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) compares the options for the size of machine this guide assumes.

#### var/ must be writable, and where logs go

Symfony writes to exactly one tree at runtime: `var/`. Within it, `var/cache/` holds the compiled cache and `var/log/` holds log files, and sessions land in `var/sessions/` if you use the file handler. The web process must be able to create and write files there. Nothing else in the project needs write access, including `src/`, `config/` and `vendor/`.

The symptom of a non-writable `var/` is unmistakable once you have seen it: a 500 error and, in the console or `var/log`, a message beginning `Unable to create the "cache" directory` or `Failed to write file`. The cure is to fix ownership, not to loosen permissions to `777`:

```bash
$ chmod -R ug+rwX var
```

Logging deserves a decision. The recipe's Monolog configuration for `prod` buffers records with a `fingers_crossed` handler, which only writes the whole request's context once an error-level record arrives, and sends the result to `php://stderr` in JSON. On a container that is the right default, because stderr goes straight to the console where you can read it live, and nothing fills the disk. If you switch the handler to a stream under `var/log/prod.log` for long retention, rotate it: Monolog's `rotating_file` handler with a `max_files` value stops a log from growing until the plan's disk is full. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) discusses what to keep and for how long.

Uploads are a separate matter. Files users upload should live somewhere you can back up, not in `var/`, which is disposable by design. Put them on a path outside the cache tree, or in S3-compatible object storage, and include the path in your backup plan. A backup nobody has restored is a hypothesis.

#### Behind the proxy slot: trusted proxies and X-Forwarded-For

Almost every production Symfony app sits behind something that terminates TLS. On RE:NODE, app plans include a proxy slot: point an A record at the address the panel shows, and the certificate is issued and renewed for you, inside a 21-day window. The real client IP arrives in the `X-Forwarded-For` header. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the path, and [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained) covers why issuance fails when DNS is not ready.

The consequence for Symfony is that by default it does not believe forwarded headers. Without configuration, `$request->getClientIp()` returns the proxy's address, `$request->isSecure()` is false, and `UrlGeneratorInterface` builds `http://` links on an HTTPS page, which browsers block as mixed content. Rate limiters and login throttling then count every visitor as one client, which is worse than it sounds.

Tell Symfony which proxies to trust and which headers to read, in `config/packages/framework.yaml`:

config/packages/framework.yaml:

```yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    trusted_headers:
        - 'x-forwarded-for'
        - 'x-forwarded-host'
        - 'x-forwarded-proto'
        - 'x-forwarded-port'
        - 'x-forwarded-prefix'
```

Then set the variable. Symfony accepts a comma-separated list of IPs and CIDR ranges, plus two special values: `PRIVATE_SUBNETS` trusts the loopback and private ranges, and `REMOTE_ADDR` trusts whatever address the connection arrives from.

```env
TRUSTED_PROXIES=PRIVATE_SUBNETS
```

The trade-off is the important part. `REMOTE_ADDR` is correct only when the proxy is the single way into the application, because anything allowed to connect can then forge `X-Forwarded-For` and pretend to be anyone. The platform copy does not state the proxy's own address, so the reliable method is empirical: deploy a route that prints `$request->server->get('REMOTE_ADDR')` while logged in as you, read the value, and put that address or range in `TRUSTED_PROXIES` instead of a wildcard. Remember that behind a trusted proxy the header can contain a chain of addresses, and Symfony picks the last untrusted one, which is why the list has to be right and not just broad.

Set `DEFAULT_URI` or the router's `default_uri` too (the older spelling is the `router.request_context.host` and `scheme` parameters) so that URLs generated in console commands and Messenger handlers, which have no request to read a scheme from, use `https://` and your real hostname.

#### A health route and what to watch

Symfony has no built-in health endpoint, which is easy to fix and worth fixing. A good one returns quickly, does not touch anything slow, and optionally checks the database separately:

src/Controller/HealthController.php:

```php
<?php

namespace App\Controller;

use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class HealthController
{
    #[Route('/healthz', name: 'health', methods: ['GET'])]
    public function live(): JsonResponse
    {
        return new JsonResponse(['status' => 'ok']);
    }

    #[Route('/healthz/db', name: 'health_db', methods: ['GET'])]
    public function db(Connection $connection): JsonResponse
    {
        $connection->executeQuery('SELECT 1');

        return new JsonResponse(['status' => 'ok']);
    }
}
```

Keep the liveness route free of database calls, so a database blip does not make a monitor conclude that the web process is dead. Make sure your firewall configuration leaves `/healthz` public (for example `^/healthz` under `access_control` with `PUBLIC_ACCESS`), and that it is not caught by a locale prefix. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) goes through what to probe and why.

The panel's own view matters just as much. The console graph shows memory, CPU and disk against the plan limits, and RE:NODE has two behaviours worth knowing for a PHP service. At the memory limit the kernel stops the container and it restarts clean instead of swapping, so a leak or an oversized cache warm-up shows up as a restart with nothing in the application log. And a watcher polls every two minutes for uptime that went backwards: three restarts in an hour raise a warning on the server page and an automatic ticket, and six lead to suspension. A crash loop on a bad deploy, such as a missing migration, is therefore something to stop and fix promptly rather than watch.

Size the plan by measurement, not by hope. A Symfony app in `prod` mode without worker mode commonly uses tens of megabytes per request, and a cold-cache first request uses several times that; [sizing a web app for launch day](https://www.renode.space/blog/sizing-a-web-app-for-launch-day) turns that into a method, and [PHP settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers `memory_limit` and OPcache.

#### Deploying: git, SFTP and the first-start page

There are two ways code reaches the server, and both are on the panel.

- **Git deploy.** GitHub only, through a GitHub App with short-lived tokens, so private repositories work. Two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. Every deploy gets its own record. Because Composer installs on start, this combination gives you "push, restart, new dependencies" without a build server.
- **File manager or SFTP.** SFTP credentials are per server, not per account. The file manager unpacks archives in place, which suits uploading a zipped release. Do not upload `vendor/`, `var/` or any `.env.local` from your machine.

A new server on this line starts with a small working page on its address until you deploy code, and that page never overwrites files you have uploaded, so the first sight of the address is the placeholder, not your application. Once the code is there, a deploy sequence that you can run the same way every time looks like this:

```bash
$ composer install --no-dev --optimize-autoloader --no-interaction
$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
$ php bin/console cache:clear
$ php bin/console messenger:stop-workers
```

Run it from the panel console where you can, and keep each step in the same order every time: install, migrate, rebuild the cache, restart workers. Take a backup first when a migration drops anything. For a deploy that is invisible to visitors rather than merely quick, [zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers the release-directory pattern, with the caveat that it needs more disk and more care on a plan with one container.

#### Troubleshooting

**A blank page, or a bare 500, in production.** That is `APP_DEBUG=0` doing its job. Read the console output first, since Monolog sends errors to stderr, then `var/log/prod.log` if you changed the handler. If both are empty, PHP could not write them: check `var/` permissions.

**The profiler toolbar appears, or the exception page shows file paths.** `APP_ENV` or `APP_DEBUG` is still the development value. Set `APP_ENV=prod` and `APP_DEBUG=0` as real variables, and check that no stray `.env.local.php` is overriding them.

**"Unable to create the cache directory" or "Failed to write file".** `var/` is not writable for the process that runs PHP, or an earlier command created `var/cache` under a different user. Fix ownership, then run `cache:clear` from the console.

**"Class DebugBundle not found" after a deploy.** Dev packages were removed with `--no-dev`, but the container was built in the `dev` environment. Set `APP_ENV=prod` before Composer runs.

**Everything works except links, which are http:// and redirect loops on login.** Trusted proxies are not configured, so Symfony cannot see HTTPS. Set `TRUSTED_PROXIES` and the `trusted_headers` list shown above.

**A change to an environment variable does nothing.** A real variable needs a restart of the server to reach PHP. If you also use `composer dump-env`, the compiled `.env.local.php` still holds the old value. Dump again or delete it.

**"An exception occurred in the driver: could not translate host name".** The `DATABASE_URL` host is wrong or the password has unencoded special characters. Copy the host and port from the panel, and encode the password.

**Messages sit in the queue and nothing consumes them.** No consumer is running, or it is running the old code. Start one, and make `messenger:stop-workers` part of every deploy so a long-lived worker is replaced.

**The server restarts on its own, with nothing in the log.** The memory limit. Look at the memory graph around the restart time. Lower `--memory-limit` on the worker, warm the cache in the deploy rather than on the first request, and read [Linux swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer) for what a kernel stop means.

#### FAQ

##### Do I need worker mode to run Symfony on FrankenPHP?

No. FrankenPHP serves Symfony in its ordinary mode, one boot per request, exactly as PHP-FPM would. Worker mode is a performance option that keeps the kernel in memory between requests. Switch it on after the application is stable, and test for state that leaks from one request into the next.

##### Where should production secrets live: .env or real environment variables?

In real environment variables or the Symfony secrets vault. `.env` is committed and documents the names. A real variable always wins over any file because Dotenv does not overwrite existing values. On RE:NODE the Startup tab is where environment variables go.

##### Is composer install --no-dev safe for a Symfony app?

Yes, as long as nothing in production code relies on a dev package and `APP_ENV=prod` is set before the install. If a "class not found" error names a package, it belongs in `require`, not `require-dev`. Maker, the profiler and PHPUnit should stay in `require-dev`.

##### Why does Symfony see the proxy's IP instead of the visitor's?

Because it distrusts forwarded headers until told otherwise. Set `framework.trusted_proxies` to the proxy's address or range, list the `x-forwarded-*` headers under `trusted_headers`, and `getClientIp()` returns the address from `X-Forwarded-For`. Avoid trusting everything if your origin is reachable directly.

##### Do Doctrine migrations run automatically on deploy?

Not by themselves. You must run `doctrine:migrations:migrate` as a step, and make it non-interactive with `--no-interaction`. Run it before the new code serves traffic where the change is not backward-compatible, and take a backup before destructive ones.

##### Can the Messenger worker run in the same server as the website?

A server is one container with one process, so plan the worker as a second server using the same code, variables and database. Whether a plan's start command can be changed is something to confirm on its Startup tab before you commit to that layout.

The Laravel / PHP line runs Laravel or Symfony on FrankenPHP with PHP 8.3, 8.4 or 8.5, installs Composer dependencies on start, and starts with a small working page that never overwrites your files. Plans are from $4 a month, with database and backup slots, environment variables on the Startup tab, GitHub deploys, and a proxy slot that issues and renews your certificate.

[See Laravel / PHP plans](https://www.renode.space/app-hosting#laravel-php)

---

### Pre-flight check: catch a broken GitHub deploy before it runs

Published 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/pre-flight-check-catch-broken-deploys

Most deploys that fail do not fail at the deploy. The code is fetched, the dependencies install, the container starts, and then the app exits because there is no start script, or it listens on port 3000 where nothing outside can reach it, or it looks for a `.next` folder that nobody built. Every one of those mistakes is already visible in the repository before a single byte is deployed. The pre-flight check on the panel's GitHub tab reads the repository, compares it with how the server will actually boot it, and lists what will break, worst first, with a fix you can copy.

This post covers what the check reads, how to read the card, every class of problem it finds with a real example of the config that triggers it, and how to fix each one. It also covers what it cannot see, because a green card is not a promise that the app works. Most of the fixes apply on any host: a hard-coded port or a `localhost` bind breaks a deploy everywhere, not only on RE:NODE.

#### What the check reads, and what it never touches

The check works from two things: one recursive tree listing of the branch, and a short list of small files fetched by name. The tree listing is paths and sizes only, no content. The files it may read are the ones that decide how the app boots: `package.json`, `deno.json` or `deno.jsonc`, `go.mod` and the Go files of the main package, `requirements.txt` or `pyproject.toml`, `.nvmrc`, `.env.example`, and the entry file the server would run. It never clones the repository, and it skips any file over 256 KB, because nothing worth checking at boot is that large.

It runs in two places. When you pick a repository and branch on the GitHub tab, it runs before the link is saved, so you see the problems before anything is deployed. After that, it runs again on every visit to the tab. Results are cached per commit for ten minutes, but the branch head is asked every time, so a push you just made is noticed at once and the cache only saves re-reading files that have not changed.

Three things it deliberately does not do:

- **It never opens a `.env`.** A committed `.env` is reported from its file name alone. Its contents are never fetched, so nothing in it can end up in a finding.
- **It never quotes a file back to you.** The output is a list of findings - sentences written by the panel, quoting at most a script or a variable name. Every string passes through the same redactor the AI features use, so a start script with a password inlined in it is quoted with the password withheld.
- **It never fails loudly.** GitHub down, a rate limit, the GitHub App uninstalled, a branch that does not exist yet: each becomes a single note saying the check could not run. A check that took the whole tab down with it would be worse than no check.

```diagram
title: Where the pre-flight check sits in a GitHub deploy
repo = GitHub repository | branch head
check = Pre-flight check | tree + a few files
card = Pre-flight card | worst first
link = Link saved
start = Server start | pull, install, boot
repo -> check : read only
check -> card
card -> link : you fix, then link
link -> start
```

The check follows the same boot logic the server uses. It does not have a separate idea of what a Node or Go app should look like; it reads the list of entry files the startup script falls back to and the variable the Startup tab uses for each runtime. When the boot changes, the check changes with it.

#### Reading the pre-flight card

The card sits on the GitHub tab under the heading "Pre-flight check", with a line saying which commit and branch it read. Each finding has one of four levels, and the list is sorted with the worst at the top:

| Level | Label on the card | What it means |
|---|---|---|
| fail | Will break | The app will not start, or will start and be unreachable |
| warn | Worth fixing | It will run, badly or not as intended |
| info | Good to know | Context, or something the check could not decide |
| pass | Fine | This part matches how the server boots |

Every finding has a title, a sentence of detail, and - where there is one - a fix in a small code box with a **Copy** button. The fix is written for this server: when it says `$SERVER_PORT`, that variable really is set at boot. The **Re-run** button runs the check again on demand.

What the check tests depends on the server's runtime. Node.js, Bun, Deno, Go and Python servers get the full set: start command, port and host, build, version. Web hosting gets the index-file and build checks. Other server types, such as Java, .NET or Rust, run their own build and start command, so they get only the secrets checks, and the card says so rather than staying quiet.

> [!NOTE] One edge case to know: on a very large repository, GitHub can return a partial file list. The card then says "Large repository: only part of it was read", and a file reported missing may exist after all.

#### Nothing to start: start scripts and entry files

The most common failure is an app with nothing for the server to run. The container starts, finds no command, and exits within a second. The console shows a short burst and then nothing, which is confusing if you are not expecting it.

For a Node.js server, the boot runs `npm install` and then `npm run start`. If `package.json` has no `start` script, it falls back to the first entry file it finds in a fixed list: `index.js`, `server.js`, `app.js`, `main.js`, `bot.js`, `index.mjs`, `index.cjs`, the same names under `src/`, and then the TypeScript versions such as `index.ts` and `src/server.ts` (run with ts-node). If neither exists, the card shows "No start script and no entry file" at fail level.

The `package.json` that triggers it usually looks like this - a project that has only ever been run with `npm run dev` on a laptop:

package.json (before):

```json
{
  "name": "my-api",
  "main": "src/api.js",
  "scripts": {
    "dev": "nodemon src/api.js"
  },
  "dependencies": { "express": "^4.19.2" }
}
```

`src/api.js` is not one of the fallback names, and there is no `start`. The fix is one line:

package.json (after):

```json
{
  "scripts": {
    "dev": "nodemon src/api.js",
    "start": "node src/api.js"
  }
}
```

A `package.json` that does not parse is reported on its own, before anything else, because npm refuses it and nothing after that point matters. The fix the card offers is to run `npm install` locally and read the error. A trailing comma is the usual culprit.

The other runtimes have their own versions of the same check:

- **Bun** runs `bun install` and then `bun run start` if `package.json` has a start script, otherwise the entry file named on the Startup tab, otherwise the first file from the same fallback list. If the Startup tab names a file the branch does not have, but a fallback exists, the card tells you which file will actually run and suggests setting the Startup tab to match.
- **Deno** runs `deno task start` from `deno.json` or `deno.jsonc`. With no start task, it runs the Startup tab's entry file with `deno run -A` - which grants every permission, and the card says so. The fallback names are `main.ts`, `mod.ts`, `server.ts`, `index.ts`, `main.js`, `src/main.ts` and `src/index.ts`. A `package.json` without a `deno.json` gets a note: nothing runs `npm install` on a Deno server, so either let Deno read the dependencies itself or use `npm:` specifiers.
- **Python** runs the entry file named on the Startup tab. If none is named, the panel looks for `main.py`, `bot.py`, `app.py`, `run.py` and `__main__.py` in that order and points the Startup tab at the first one when you link. If the Startup tab names `server.py` and the branch has no such file, that is a fail, with the fix naming the candidate it did find.
- **Go** builds the package on the Startup tab (`GO_MAIN`, `.` by default) with `go build`, and a failed build stops the start. The check wants a `go.mod`, Go files in that folder, `package main`, and a `func main()`. A folder holding a library package is reported as such, because `go build` on a library produces no program.

The Go case catches a layout that trips up a lot of people: the module root holds a library package and the program lives in `cmd/server`. Set `GO_MAIN` on the Startup tab to `./cmd/server` and the finding turns green. If there is no `go.mod` at all, run `go mod init` with your module name, then `go mod tidy`, and commit both `go.mod` and `go.sum`.

#### Wrong port, wrong host

The second most common failure is an app that starts fine and cannot be reached. It is the most frustrating, because the console says "listening" and the browser says nothing.

Only the server's own port is reachable from outside. The panel sets two environment variables at boot, `PORT` and `SERVER_PORT`, to that port. An app that hard-codes 3000, 5000, 8000 or 8080 is listening on a port that nothing routes to, unless your allocation happens to be that number. And an app bound to `localhost` or `127.0.0.1` is reachable only from inside its own container, whatever port it uses. For a fuller explanation of why, see [game server ports explained](https://www.renode.space/blog/game-server-ports-explained) - the same rules apply to apps.

The check reads the start script first and then the entry file, and reports:

- a start script that fixes the port, such as `next start -p 3000` or `--port 8080` (warn);
- an entry file that calls `.listen(3000)` or sets `port = 5000` (warn);
- an entry file that binds `localhost` or `127.0.0.1` (warn);
- an entry file that reads `process.env.PORT`, `SERVER_PORT`, `Bun.env.PORT` or `Deno.env.get("PORT")` (pass);
- no web server found at all (info) - fine for a Discord or Telegram bot, worth a second look if you expected HTTP.

This Express entry file triggers two findings at once, the hard-coded port and the localhost bind:

server.js (before):

```js
const express = require('express');
const app = express();

app.get('/', (req, res) => res.send('ok'));

app.listen(3000, 'localhost', () => console.log('listening on 3000'));
```

The fix the card suggests:

server.js (after):

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

app.listen(port, '0.0.0.0', () => console.log(`listening on ${port}`));
```

The same idea in the other runtimes. Python: read the port with `int(os.environ.get("SERVER_PORT", os.environ.get("PORT", 8080)))` and pass `host="0.0.0.0"`. Go: `http.ListenAndServe(":"+os.Getenv("PORT"), mux)` - a bare colon listens on every interface, and a `"localhost:8080"` string is reported. Bun: `Bun.serve({ port: Number(process.env.PORT), hostname: '0.0.0.0', fetch })`.

Deno has a special case worth knowing. `Deno.serve(handler)` with no port listens on 8000, which is not your allocation. A generic check would only say it could not tell which port is in use; this one names the problem:

main.ts:

```ts
Deno.serve(
  { port: Number(Deno.env.get("PORT")), hostname: "0.0.0.0" },
  (_req) => new Response("ok"),
);
```

Sometimes the card says "Could not tell which port" at info level. That happens when the server is created somewhere the check does not read, such as a file imported from the entry file. It is not an error; open the file that calls `listen` and check it by eye.

> [!TIP] Production servers in Next.js, Nuxt, Remix and React Router bind the `PORT` environment variable on their own. A plain `next start` is fine. `next start -p 3000` is not.

#### Builds that never ran

Frameworks with a build step fail in a particular way: the start command looks for build output that does not exist. The check knows four of them and what each one produces - Next.js builds `.next`, Nuxt builds `.output`, Angular and Vite build `dist`. If the project depends on one of these, it asks three questions: does the start script, `prestart` or `postinstall` run a build; if not, is the build output committed; and if neither, it is a fail.

A typical Next.js `package.json` that fails:

package.json (before):

```json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}
```

`build` exists, but nothing calls it on the server. The fix the card gives builds and then serves on the right host and port:

package.json (after):

```json
{
  "scripts": {
    "build": "next build",
    "start": "next build && next start -H 0.0.0.0 -p $SERVER_PORT"
  }
}
```

The alternatives are a `postinstall` that runs the build, or committing the build output. Committing `.next` works and the card passes it, with a reminder that you then have to rebuild before every push. Most people forget within a week. We would rather build on the server.

A start script that runs a development server is a separate warning. `next dev`, `nuxt dev`, `ng serve` and a bare `vite` are slow, unminified, and often bound to localhost. For Vite, the suggested fix is `vite build && vite preview --host 0.0.0.0 --port $SERVER_PORT`; for Nuxt, `nuxt build && node .output/server/index.mjs`. The [Next.js deploy guide](https://www.renode.space/blog/deploy-nextjs-on-a-node-server) covers the memory side of building on a small server.

Plain TypeScript gets the same treatment without a framework. If `typescript` is a dependency and the start script runs `node dist/index.js`, but `dist/index.js` is not committed and nothing runs `tsc` before the start, the card reports "TypeScript is never compiled" and suggests `tsc && node dist/index.js`.

#### Runtime version mismatches

A project written for one major version of a runtime can fail to install or run on another, usually on a native module or a syntax change. The check compares what the repository asks for with what the server runs.

For Node.js it reads `.nvmrc` first and then `engines.node` in `package.json`. `.nvmrc` containing `20` on a server running Node 22 is a warning. With `engines`, a `>=18` range passes on anything from 18 up; a `^20` or a bare `20` passes only on Node 20. The fix offered is either to pick the matching version under **Runtime version** on the same tab, or to update the file. Choose by which is true: if the project really needs Node 20, change the server; if `.nvmrc` is a leftover from two years ago, change the file.

.nvmrc:

```text
22
```

For Go, it reads the `go` line in `go.mod`. If `go.mod` asks for a newer Go than the server has, the build either downloads the newer toolchain before every start, which needs network and disk each time, or refuses to build. Pick that Go version or newer under Runtime version, or lower the `go` line if the code does not actually need it.

The version check only runs when the server's image names a version the check can read. If it cannot tell, it stays silent rather than guessing.

#### Python, web hosting and other runtime specifics

Python has two findings that catch people coming from other platforms.

The server installs `requirements.txt` with pip before the start. A project that declares its dependencies only in `pyproject.toml` gets a warning, because nothing in it is installed. Export a requirements file and commit it:

```bash
$ pip freeze > requirements.txt
$ uv pip compile pyproject.toml -o requirements.txt
```

Either command works; the second keeps the file to what `pyproject.toml` declares rather than everything in your local environment. The [Python requirements guide](https://www.renode.space/blog/python-requirements-and-virtualenvs) goes further into pinning.

A `Procfile` is reported as not read. The server runs the entry file on the Startup tab, not the command in a `Procfile`. If your `Procfile` says `web: gunicorn app:app`, that command is ignored, so make sure the entry file starts the server itself.

Web hosting is simpler and stricter. The host serves the folder as it is and never runs `npm run build`. The check wants an `index.html`, `index.php` or `index.htm` at the root. If it finds none but sees `dist/index.html`, `build/index.html`, `out/index.html`, `public/index.html` or `_site/index.html`, it says so and suggests committing the built files or pointing the deploy path at that folder. A project with a `build` script and no built output committed gets a separate warning for the same reason. See the [static site hosting guide](https://www.renode.space/blog/static-site-hosting-guide) for the build side.

#### Secrets: a committed .env and .env.example

This is the only finding that is about safety rather than whether the app starts, and it runs for every server type.

Any file named `.env`, `.env.local`, `.env.production`, `.env.prod`, `.env.development` or `.env.dev`, anywhere in the tree, is a fail: "A `.env` is committed to the repository". Every password and key in it is readable by anyone who can read the repository, in every commit since it was added. On web hosting it is worse: the file is served with the rest of the folder, so it can be downloaded from the site itself.

> [!WARNING] Removing the file from the repository does not un-leak it. It is still in the history. Change every password and key it held, then upload a new `.env` through the Files tab.

The fix, in order:

```bash
$ git rm --cached .env
$ echo ".env" >> .gitignore
$ git commit -m "Stop tracking .env"
$ git push
```

Then rotate the credentials - database passwords, API tokens, bot tokens - and create the new `.env` on the server through the Files tab. A `.env` is never fetched from GitHub, so the server only ever has the one you put there. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the wider practice.

A `.env.example` gets an info finding that lists the keys it declares, up to thirty, so you can see at a glance what the server's `.env` needs. Values in the example are not shown. If a project uses `dotenv` or `python-dotenv` and has no `.env.example`, the card notes that the app reads a `.env` and reminds you to upload one.

#### What the pre-flight check cannot catch

The check reads files. It does not run anything, and that sets its limits.

- **Runtime errors.** A missing environment variable that crashes the app on the first request, a database that refuses the connection, a typo in a route - none of these are visible in a file listing.
- **Memory.** A Next.js build that needs more RAM than the plan has will pass the check and fail at build time. [Node memory limits](https://www.renode.space/blog/node-memory-limits-explained) explains how to tell.
- **Indirect ports.** A port read from a config file or set in an imported module may show as "Could not tell". Check it by hand.
- **Private dependencies.** A package from a private registry needs credentials at install time; the check does not test installs.
- **Anything outside the branch.** Uploaded files, the server's own `.env`, and database contents are not part of the repository.

A green card means the boot will find something to run, on the right port, built and on a matching version. That removes the boring failures, so what is left in the console is a real bug.

When the console does show a crash, the "Work on this server with AI" bar on the Console tab has a "Server won't start" option that copies a ready-made prompt with the console tail. The pre-flight findings travel into the AI context too, so an assistant asked to fix the repository starts from what is already known to be wrong. The [GitHub deploy guide](https://www.renode.space/blog/deploy-a-node-app-from-github) covers linking and deploy on push, and [the deploys section of our guides](https://www.renode.space/guides#deploys) covers the switches.

#### FAQ

##### Does the pre-flight check block a deploy?

No. It reports; it does not stop you. You can link a repository with fail-level findings, and the deploy will go ahead and fail the way the card predicted. The point is to see it before rather than after.

##### Why does the card say a file is missing when it is in my repository?

Three usual reasons: the file is on a different branch from the one linked, the deploy path points at a subfolder that does not contain it, or the repository is large enough that GitHub returned a partial file list, which the card flags at the top. Check the branch name in the card's header line first.

##### I fixed the problem and pushed. Why does the card still show it?

It should not for long. The branch head is checked on every visit, so a new commit triggers a fresh read. If it still shows, press Re-run, and check that you pushed to the branch the server is linked to rather than to another one.

##### Can the check see the values in my .env?

No. A committed `.env` is reported by its name and its contents are never requested. The `.env.example` is read, but only its key names appear on the card. Every string on the card also passes through the redactor before it is shown.

##### Which runtimes does the check cover?

Node.js, Bun, Deno, Go and Python get the start, port, build and version checks; web hosting gets the index-file and build checks. Other server types get the committed-secrets checks and a note that runtime checks do not apply.

##### Does it work with GitLab or Bitbucket?

No. Git deploy on RE:NODE is GitHub only, through a GitHub App, and the check reads the repository through that App's access - so private repositories work, as long as the App can see them.

Node.js, Python, Go, Bun and Deno servers link to GitHub through a GitHub App, deploy on push, and run the pre-flight check on the GitHub tab before the first deploy. Plans start at $4 a month.

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

---

### Starter templates: your first app on a new server in 5 minutes

Published 2026-10-16 by RE:NODE Team. https://www.renode.space/blog/starter-templates-first-app-in-5-minutes

The fastest way to a running app on a new server is not to write the app first. It is to start from a small repository that already does the four things a hosted server insists on - listens on `0.0.0.0` on the port it was given, has a start script or entry file the runtime can find, pins a runtime version the server offers, and keeps secrets out of Git - and then change it into your app one commit at a time. Create a repository from a template, point the server at it, press deploy, and the first start shows a working page instead of a stack trace. Everything after that is ordinary development: you push, the server pulls and restarts.

This post covers what a starter template is and how it differs from the placeholder page a new server shows, the rules every starter follows and why, a step-by-step from empty server to live app, what is inside each of the six starters we keep (Next.js, Express, a Discord bot, a Telegram bot, FastAPI and a static site), how to customise them without breaking the deploy, and how to set up deploy on every push. Most of it applies on any host that deploys from Git; the RE:NODE parts are marked as such.

#### Template, starter page, one-click app: three different things

People use "starter" for several things, and mixing them up causes most of the confusion on day one.

A **GitHub template repository** is a normal repository with one box ticked in its settings. Instead of forking it, you press **Use this template** and GitHub creates a brand new repository in your account with the same files and a clean history. There is no link back to the original, no upstream to keep in sync, and nobody else sees your commits. It is yours from the first second. That is the right shape for a starter: you are not contributing to the template, you are taking it as a skeleton.

A **starter page** is what a new, empty runtime server shows before you deploy anything. On RE:NODE, every runtime line except Node.js starts with a small working page on its address until you deploy code, and it is never written over your files. It exists so that the first start of a fresh server says "here is what to do next" instead of crashing with `Cannot find module './index.js'`. It is not an app you build on - deploying your repository replaces it.

A **one-click app** is a finished product installed for you: WordPress on the web line, Ghost and Strapi on the app line. You configure them, you do not write them. If what you want is a blog, one of those is the shorter road, and the [Ghost guide](https://www.renode.space/blog/ghost-blog-hosting-guide) or the [Strapi guide](https://www.renode.space/blog/strapi-production-deployment-guide) is the better next read.

A starter template sits between the two: real code, small enough to read in five minutes, already shaped for the server it is going onto.

#### The four rules every starter follows

A hosted app server is not your laptop. It runs one process in a container, it gives that process exactly one public port, and it starts the process the same way every time. Almost every failed first deploy breaks one of the following rules, which is why every starter is built around them.

| Rule | What it looks like | What breaks without it |
|---|---|---|
| Bind `0.0.0.0` on the given port | `SERVER_PORT` from the environment | App runs, nobody can reach it |
| A start the runtime can find | `start` script or `app.py` | Crash on start, nothing to run |
| A pinned runtime version | `.nvmrc`, `.python-version` | Works locally, fails on the server |
| Secrets outside Git | `.env` in `.gitignore` | Tokens leaked in the repository |

**The port.** On a panel built on Pterodactyl, the port your server owns is passed to the process as an environment variable, `SERVER_PORT`. Our Node.js and Python starters also fall back to `PORT`, which many platforms use, and then to a local default so the same file runs on your machine. The address matters as much as the port: an app that listens on `localhost` or `127.0.0.1` answers only inside its own container. It looks healthy in the console and is unreachable from the internet. `0.0.0.0` means every interface, and it is the only address that works.

**The start.** The runtime needs to know what to run. For Node.js that is the `start` script in `package.json`. For Python it is a file named on the Startup tab, `app.py` by default, run after `requirements.txt` is installed. A web hosting server needs neither - its own web server serves an `index.html` from the web root.

**The version.** `.nvmrc` and `.python-version` say which major version the code was written for. The server still runs whatever version is picked under **Runtime version** on the GitHub tab, so the file is a reminder and a check, not a switch - but the pre-flight check reads it and tells you when the two disagree, which is worth a lot when Next.js stops supporting the version you happen to have picked.

**The secrets.** Every starter ships a `.env.example` with the names of its settings and fake values, and a `.gitignore` that keeps the real `.env` out. You fill in the real values on the server. More on why and how in [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets).

#### The six starters and what they run

We keep six starter repositories, each built and tested against the panel's own pre-flight check so that a repository created from one reports nothing to fix. They are offered only on servers whose runtime matches.

| Starter | Server it fits | What you get |
|---|---|---|
| Next.js | Node.js, Node 22 | App Router site with a health route |
| Express API | Node.js, Node 22 | JSON API: `/`, `/health`, `/echo` |
| Discord bot | Node.js, Node 22 | discord.js bot with `/ping` |
| Telegram bot | Python, 3.12 | Echo bot on long polling |
| FastAPI | Python, 3.12 | API with docs at `/docs` |
| Static site | Web hosting | HTML and CSS, nothing to build |

There is no starter yet for Bun, Deno, Go, Java, Rust, .NET or Laravel. The Node starters read `.env` with a Node-only call and pin a Node version, which Bun is not guaranteed to honour, and we would rather offer nothing than a template we have not checked. The last section covers how to start those runtimes from a blank repository.

> [!NOTE] The "Start from a template" list on the GitHub tab is not switched on for every account yet. If your server's GitHub tab does not show it, nothing below changes: each starter is an ordinary set of files, and the steps work the same from a repository you create yourself.

#### From empty server to running app, step by step

This is the whole path for the Express starter on a Node.js server. The other starters differ only in the server you order and the settings you fill in.

1. Order a server that runs the code - for Express, a Node.js plan from [app hosting](https://www.renode.space/app-hosting). It is created when payment clears, usually inside a minute.
2. Open the server's **GitHub** tab and press **Connect GitHub**. This installs the RE:NODE GitHub App on your account and lets you choose which repositories it can see. The App uses short-lived tokens, so private repositories work.
3. Under **Start from a template**, press **Use template** on Express API. This opens GitHub's own "create a repository from a template" page with a suggested name. Pick the owner, choose public or private, and create it. The panel creates nothing in your account itself - GitHub does, where you are already signed in.
4. Come back to the panel tab. The repository list refreshes when the tab regains focus. If you shared only selected repositories with the App, add the new one under **Manage repository access** first.
5. Pick the repository, leave the production branch on `main`, and pick **Runtime version** - Node 22 for every Node starter.
6. Press **Deploy**. The panel asks for confirmation, because this reinstalls the server from the repository. On a new server there is nothing to lose.
7. Start the server. The console shows the dependency install, then `Listening on 0.0.0.0:` followed by your port.
8. Open the server's address and port in a browser. You get a small JSON reply with a message and the app name.

That is the five minutes. The longest part is usually step 3, deciding what to call the repository.

```diagram
title: From template to running app
tpl = Starter template | on GitHub
repo = Your repository | created from it
tab = GitHub tab | branch, runtime
srv = App server | install, then start
web = Browser | address and port
tpl -> repo : Use template
repo -> tab : pick and deploy
tab -> srv : reinstall
srv -> web : 0.0.0.0:SERVER_PORT
repo --> srv : every push
```

If you would rather use a domain than an address and port, the proxy slot on app plans handles that: point an A record at the address shown and a certificate is issued and renewed automatically. Your app keeps listening on its own port; the proxy sits in front, and the real client IP arrives in `X-Forwarded-For`.

#### What is inside each starter

Each one is deliberately small. The point is that you can read all of it before you change any of it.

##### Express API

`server.js` reads an optional `.env`, sets up three routes and listens. The part that matters is the last few lines:

server.js:

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

app.listen(port, '0.0.0.0', () => {
    console.log(`Listening on 0.0.0.0:${port}`);
});
```

The `.env` is read with Node 22's built-in `process.loadEnvFile()`, so there is no `dotenv` dependency. `npm run start` runs `node server.js`; there is nothing to build. For what comes after the starter - error handling, logging, running it properly - see [deploying an Express API to production](https://www.renode.space/blog/deploy-express-api-to-production).

##### Next.js

The Next.js starter is an App Router site with a home page and an `/api/health` route. Its start script builds and then serves:

package.json:

```json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next build && next start -H 0.0.0.0 -p $SERVER_PORT"
  }
}
```

Building on start means every restart rebuilds the site. That is slower than serving a committed build, but it means the server always runs exactly the commit it pulled, and there is no `.next` folder in Git to go stale. The cost is memory: `next build` is the hungriest thing a small Next.js site does all day. If the console shows the build dying on the smallest plan, that is the memory limit, not a bug - at the limit the container is stopped and restarted clean rather than swapping. Watch the memory graph during a build and size up if it touches the line. [Deploying Next.js on a Node server](https://www.renode.space/blog/deploy-nextjs-on-a-node-server) goes further into standalone output and build caching.

##### FastAPI

`app.py` creates the app and runs uvicorn itself, rather than relying on a command line:

app.py:

```python
if __name__ == "__main__":
    port = int(os.environ.get("SERVER_PORT", os.environ.get("PORT", "8000")))
    uvicorn.run(app, host="0.0.0.0", port=port)
```

Keeping the bind address and port in the code is the reason: the Python server runs a file, and a file that starts its own server cannot be started on the wrong address by a forgotten flag. `requirements.txt` pins `fastapi`, `uvicorn` and `python-dotenv` to exact versions and is installed before every start. The interactive docs are at `/docs` from the first run. The [FastAPI and Flask deploy guide](https://www.renode.space/blog/deploy-fastapi-or-flask) covers workers and production settings.

##### Discord bot

A discord.js bot with one global slash command, `/ping`. It reads `DISCORD_TOKEN` and exits with a clear message if it is missing, rather than failing somewhere inside the library. A bot needs no inbound port, but if the server has one, the starter answers a small JSON health check on it, so an uptime monitor has something to ask. It asks Discord only for the `Guilds` intent; reading message content needs the privileged intent switched on in the Discord developer portal and added in code. [Hosting a Discord bot 24/7](https://www.renode.space/blog/host-a-discord-bot-24-7) covers the rest.

##### Telegram bot

A python-telegram-bot echo bot that answers `/start` and repeats any text back. It uses long polling: the bot asks Telegram for new messages, so nothing has to reach it from outside and it needs no port and no domain. That is the simplest setup and the right default; webhooks are worth it only at volume, and they bring their own failure modes, covered in [Telegram webhook troubleshooting](https://www.renode.space/blog/telegram-bot-webhook-troubleshooting).

##### Static site

`index.html`, `404.html` and a stylesheet at the root, for a web hosting server. There is no start script and no port to bind: the server's own web server serves the files as they are. Anything in the web root can be downloaded by a visitor, so this one has no secrets at all. If you move to a bundler later, either commit the built output or point **Where to put the code** at the folder that holds it.

#### Customising without breaking the deploy

The starters are meant to be changed. A few changes are safe at any time, a few need care.

**Safe to change freely:** routes, pages, handlers, the app name, styles, adding files and folders, adding dependencies. Add a package with `npm install <name>` or by adding a line to `requirements.txt`, commit the updated lock file or requirements, and push. The server installs what is listed on the next start. Commit `package-lock.json`; it is what makes the install on the server match the one on your machine. The difference between the two install commands is in [npm ci vs npm install](https://www.renode.space/blog/npm-ci-vs-npm-install).

**Change with care:** the `listen` call, the `start` script and the entry file name. These are the four rules, and they are where a refactor quietly breaks a working server. Moving the server into `src/index.js` is fine as long as `start` follows it. Renaming `app.py` to `main.py` means changing the file named on the Startup tab too. Wrapping the app in a framework that listens on its own default port - many do, often 3000, 5000 or 8000 - means passing it the server's port explicitly.

**Never commit:** `.env`, tokens, database passwords, private keys. Copy `.env.example` to `.env` on the server through the file manager or SFTP and fill in the real values there:

.env:

```env
APP_NAME=my-api
DISCORD_TOKEN=paste-the-real-token-here
DATABASE_URL=postgres://user:password@host:port/dbname
```

A restart or a push deploy resets the files that come from the repository and leaves an ignored `.env` alone. A reinstall is different: the first deploy, or a change of runtime version, replaces everything in the directory. Upload the `.env` after the first deploy, and keep a copy somewhere safe - it is the one file a fresh deploy cannot recreate.

> [!WARNING] A token committed once is public for ever, even if you delete it in the next commit, because it stays in the history. If it happens, rotate the token at the provider first and clean the history second.

**Adding a database.** App plans include database slots, created in the panel with a generated host, user and password. Put the connection string in `.env`, not in code. If you want the app, a database and a cache bought together, a bundle from the Bundles tab of the panel shop does that in one checkout, at a discount.

Before each push that touches the start, the port or the runtime, look at the **Pre-flight check** card on the GitHub tab. It reads the repository on GitHub - one tree listing and a few small files, never a clone - and lists what will break, worst first, with a fix to paste: a missing start script, an app bound to `localhost` or the wrong port, a missing build, a runtime version mismatch, a committed `.env` (reported by name, never opened). A starter passes it on the first commit; the card is how you know you have not broken that since.

#### Deploy on every push

The GitHub tab has two switches, and between them they decide when new code goes live.

- **Pull the latest commit every time the server starts.** A restart fetches the branch and runs the new code. Nothing happens on push; you choose when to restart.
- **Restart the server when someone pushes to this branch.** A running server restarts within seconds of the push and comes back on the new commit. A stopped server is started, so the push goes live either way.

For a hobby bot or a personal API, push-to-deploy is the right default: commit, push, and watch the console. For anything people depend on, deploy from a branch you push to on purpose. Keep `main` as where work lands and make the server's production branch something like `production` or `release`, and merge into it when you mean it. Every deploy is recorded, one record per deploy, so "what is running now" has an answer.

Two things to know before you rely on it. First, the folder named in **Where to put the code** is brought back to the repository on every start: a fix you made live in the file manager to a file that is in Git is undone on the next deploy, and a reinstall clears the folder entirely. Make the fix in Git, and keep uploads and data out of the deploy folder - in a database or object storage. Second, a restart is a restart. A Next.js site rebuilds, a bot reconnects, and requests in flight are dropped. [Zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) and [graceful shutdown](https://www.renode.space/blog/graceful-shutdown-and-health-checks) are the next step once that matters.

The deploy token the panel uses to fetch your code is minted fresh every time the server starts. There is no long-lived GitHub key on the server to leak.

#### When the first start fails anyway

A starter removes most first-deploy failures, not all of them. These are the ones left.

- **The page does not load but the console looks fine.** You are probably browsing to the wrong port, or something you added listens on its own port. Search the console for the port number in the "listening" line and compare it with the port on the server's page.
- **`DISCORD_TOKEN is not set` or `TELEGRAM_BOT_TOKEN is not set`.** The bot starters stop on purpose without a token. Upload a `.env` with the variable and restart.
- **The bot logs in, then nothing answers.** For Discord, the bot has to be invited to a server with the `applications.commands` scope before slash commands appear. For Telegram, check that no other copy of the bot - on your laptop, say - is polling with the same token; two pollers fight over the same updates.
- **Install fails with a version error.** The **Runtime version** picked on the tab is older than the code needs. Changing it rebuilds the server on the new image, which replaces the files in its directory with the repository; databases and backups are untouched.
- **The Next.js build is killed partway.** Memory limit. Check the memory graph during the build; the fix is a bigger plan or a lighter build, not a retry.
- **The server keeps going down.** A crash loop is noticed: a watcher counts crashes you did not ask for, and three in an hour put a warning on the server page and open a ticket. Read the first error in the console, not the last.
- **The repository is not in the list.** The GitHub App sees only repositories you shared with it. Add the new repository under **Manage repository access**.

#### No template for your stack

Bun, Deno, Go, Java, Rust, .NET and Laravel servers have no starter yet, but they all start with the placeholder page, and the same four rules make a blank repository deploy first time.

For **Go**, the server builds with `go build` on every start, and a failed build stops the start rather than running old code. A minimal `main.go`:

main.go:

```go
package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    port := os.Getenv("SERVER_PORT")
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hello")
    })
    http.ListenAndServe("0.0.0.0:"+port, nil)
}
```

Commit it with a `go.mod`, and the pre-flight check will compare its Go version with the one on the server. [Deploying a Go web service](https://www.renode.space/blog/deploy-a-go-web-service) has the longer version.

For **Bun**, a `start` script in `package.json` is run after `bun install`. For **Deno**, a `start` task in `deno.json` is run with `deno task start` - and note that `Deno.serve` defaults to port 8000, so pass it the server's port. [Bun or Deno on a small server](https://www.renode.space/blog/bun-or-deno-on-a-small-server) compares the two. For **Java**, any runnable jar works: Spring Boot, Quarkus, Micronaut or Ktor on Java 17, 21 or 25, with the port set from `SERVER_PORT`.

#### FAQ

##### Do I need to keep my repository in sync with the template?

No. A repository created from a GitHub template has no link to it and no shared history. Template updates do not reach you and your commits do not reach anyone else. If a later version of a starter fixes something you care about, copy the change across by hand like any other code.

##### Can my repository be private?

Yes. Choose private on GitHub's create page. The panel deploys through a GitHub App with short-lived tokens, so private repositories deploy the same way public ones do. If you later switch on the AI context pull request, a public repository gets only the general rules and none of your server's details.

##### Will deploying a template delete what is already on my server?

Yes, deploying reinstalls the server from the repository: files in its directory are replaced and anything not committed is lost. Databases and backups are untouched. On a brand new server there is nothing to lose; on a server you have been working on, take a backup first.

##### Why does my Node.js server not show the placeholder page?

The placeholder page is written on every runtime line except Node.js. A new Node.js server starts empty, so deploy a repository - a starter, or your own - before the first start.

##### Can I use a starter on a different host?

Yes. Nothing in them is tied to one panel beyond reading `SERVER_PORT` first, and they fall back to `PORT` and then to a local default. That covers most platforms that set a port variable, and your own machine.

##### How long does a push take to go live?

With push deploys on, a running server restarts within seconds of the push. The total depends on the app: an Express API is back almost at once, while a Next.js site rebuilds on start and takes as long as `next build` takes on that plan.

Node.js, Python and the other runtime lines deploy straight from GitHub, privately if you like, with a pre-flight check that reads your repository before it breaks and a switch that puts every push live. Every runtime except Node.js starts with a working page on day one.

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

---

### 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)

## 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.
