# Web Hosting - everything RE:NODE has published

> nginx and PHP with SFTP, free subdomains and a reverse proxy you control - plus one-click WordPress with its own MySQL database.

Generated from https://www.renode.space/web-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/web-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

Static sites, PHP applications, WordPress, Drupal, Joomla, Laravel. SFTP and a full file manager, a reverse-proxy slot for your domain, and NVMe storage - with none of the panel locked behind a higher tier.

### Your files are yours

A complete file manager with a text editor in the browser, plus SFTP credentials for the tools you already use. Upload a theme, edit a config, pull the whole site down - no ticket, no wait.

### A CMS without the ceremony

The WordPress line installs it for you on the first start. The Engines line is sized for the CMS you would rather install yourself - Drupal, Joomla, DLE - with enough memory for a plugin-heavy install, enough storage for the media library, and database slots included rather than sold separately.

### Your domain, your certificate

Each plan carries a proxy slot. Point a hostname at it and the certificate is issued and renewed for you; a scheduled task renews anything inside its 21-day window without being asked.

### You can see what is happening

The console shows the web server's own output. Noise from the internet knocking on doors that are not there is folded behind a count you can switch off - a real error always prints.

### Questions about this service

**Is WordPress included?**

Yes, on the WordPress line: on the first install WordPress is downloaded and wp-config.php is written with a MySQL database of its own, so you open the address and run the setup. If you would rather install a CMS yourself, the Engines plans are sized for it.

**Can I host more than one site?**

Yes, within the storage and proxy limits of your plan. Bigger tiers carry more of both.

**Do I get a database?**

Every web plan includes database slots, created from the panel with a password generated per database.

**Which PHP versions does the Laravel line run?**

PHP 8.3, 8.4 or 8.5 on FrankenPHP, a production PHP server, with Composer installing your dependencies on start. Laravel and Symfony both run on it.

### General questions

### Getting started

**How quickly is my server ready?**

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

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

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

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

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

**Can I try before buying?**

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

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

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

### Running the server

**Do I get full file access?**

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

**Can I install mods and plugins?**

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

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

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

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

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

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

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

### Backups and safety

**Are backups included?**

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

**Where are backups stored?**

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

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

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

### Billing

**How do I pay?**

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

**Can I pay in Georgian lari?**

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

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

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

**Can I upgrade or downgrade later?**

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

**Do you offer refunds?**

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

### Support and the honest bits

**How do I get help?**

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

**Where are the servers?**

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

**Is there an uptime guarantee?**

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

**Who actually runs this?**

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

## Plans and prices

Read from the live catalogue at 2026-10-15 22:30 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/web-hosting or in the panel shop at https://panel.renode.space before quoting one.

### Engines

Hosting sized for a CMS — WordPress, Drupal, Joomla, DLE or a Laravel application.

Page: https://www.renode.space/web-hosting#cms-hosting

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

### HTML CSS JS PHP

Web hosting with nginx and PHP. Static sites, PHP applications and anything in between.

Page: https://www.renode.space/web-hosting#web-hosting-php

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Starter | $3 | 1 GB | 0.5 vCPU | 5 GB | 2 | 2 | 1 |
| Standard | $4 | 2 GB | 1 vCPU | 10 GB | 2 | 3 | 1 |
| Advanced | $8 | 4 GB | 2 vCPU | 25 GB | 2 | 4 | 1 |
| Pro | $11 | 6 GB | 3 vCPU | 40 GB | 2 | 5 | 1 |
| Premium | $13 | 8 GB | 4 vCPU | 50 GB | 2 | 6 | 1 |

### WordPress

WordPress ready to set up the moment the server is: the files are in place, a MySQL database is created and connected, and the first visit opens the WordPress installer. Point your domain at it with the panel's proxy for free HTTPS.

Page: https://www.renode.space/web-hosting#wordpress

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| WordPress Starter | $5 | 1 GB | 0.5 vCPU | 5 GB | 1 | 1 | 1 |
| WordPress Standard | $8 | 2 GB | 1 vCPU | 10 GB | 1 | 2 | 1 |
| WordPress Advanced | $12 | 4 GB | 2 vCPU | 25 GB | 2 | 3 | 1 |
| WordPress Pro | $16 | 6 GB | 3 vCPU | 40 GB | 2 | 4 | 1 |
| WordPress Premium | $20 | 8 GB | 4 vCPU | 50 GB | 3 | 5 | 1 |

### Laravel / PHP

Run a Laravel or Symfony app on FrankenPHP, a production PHP server, with Composer installs on start and Artisan from the console. PHP 8.3, 8.4 or 8.5.

Page: https://www.renode.space/web-hosting#laravel-php

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


## How it runs

### Static sites and web hosting on Renode

Source: https://www.renode.space/docs/web-hosting

A web hosting server (the HTML CSS JS PHP line, and Engines) is different from an app server: its image already runs a web server - nginx, with PHP - bound to the server's port. It serves a folder, the **web root**, exactly as it is. Nothing from your repository is started, and nothing is built.

#### What has to be in the repository

- **`index.html` (or `index.php`) at the root of the served folder.** That file is your home page.
- **The built output, if your project has a build step.** A React, Vue, Svelte or Astro project that builds into `dist/` or `build/` must have that folder committed, or the path on the GitHub tab pointed at it. The server never runs `npm run build`.
- **No server process and no port.** Do not add a Node or Python server and do not bind a port: the web server is already bound to it.

A typical layout for a site built with Vite:

```text
my-site/
  src/            your source
  dist/           committed build output - the folder that is served
    index.html
    assets/
  package.json
```

Build locally with `npm run build`, commit `dist/`, and set the path on the GitHub tab to `dist`.

#### How files reach the server

On web hosting the **panel** writes your repository into the web root - not the server itself. It does so:

- when you link the repository,
- when you save changes on the GitHub tab,
- on every push to the branch, with "Restart the server when someone pushes to this branch" on.

Restarting the server fetches nothing. And files the branch no longer has are left in place rather than deleted, so remove an old file through the Files tab if it must disappear.

You can also skip GitHub entirely and upload files through the Files tab or SFTP.

#### Single-page apps

A single-page app with client-side routing has one real file, `index.html`. A reader who refreshes on `/about` asks the web server for a file called `about`, which does not exist, and gets a 404. Use hash-based routing (`/#/about`), or ask support about a fallback to `index.html` for unknown paths.

#### Never commit secrets here

Everything in the web root can be downloaded by anybody who guesses its name. Never put `.env`, credentials or private keys in the served folder. A static site that needs a secret - an API key for a payment provider, a database password - needs a server-side component on an [app server](https://www.renode.space/docs/nodejs) instead.

#### Checklist

- `index.html` or `index.php` is at the root of the served folder.
- The built output is committed, or the path on the GitHub tab points at it.
- No server process, no port binding.
- Nothing secret is in the served folder.
- If routes 404 on refresh, the app uses hash routing or a fallback has been arranged.

#### Common errors

**A directory listing or the placeholder page instead of the site.** There is no `index.html` at the root of the served folder - usually because the site builds into `dist/` and the path still points at the repository root.

**The site did not change after a push.** Restart on push is off, so the panel did not write the new commit. Save the GitHub tab, or turn the switch on. A restart alone does nothing here.

**A deleted page still loads.** Files removed from the branch are not removed from the server. Delete it through the Files tab.

**Pages other than the home page 404 on refresh.** See Single-page apps above.

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

## Panel how-to

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

### Ordering a server

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

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

Open the shop and find what you want to run:

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

Compare the plans and pick one:

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

Choose how long, then pay:

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

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

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

After you pay:

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

### The console

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

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

### Files and SFTP

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

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

### Backups

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

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

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

### Databases

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

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

### Subusers, roles and teams

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

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

### Git deploys

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

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

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

## Tutorials

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

- [Block HTTP/1.0 requests in nginx and Apache, safely](https://www.renode.space/blog/block-http-1-0-requests-nginx-apache) (2026-10-15): How to log, restrict or block HTTP/1.0 and Host-less requests in nginx and Apache: exact config, 426 vs 444 vs 403, curl tests, and the clients you might break.
- [How HTTPS works, in plain words](https://www.renode.space/blog/how-https-works-simply) (2026-10-15): What HTTPS is: encryption, certificates and authorities, the TLS handshake step by step, what the padlock proves and why every site needs it.
- [mysqli vs PDO in PHP: differences, errors and which to pick](https://www.renode.space/blog/mysqli-vs-pdo) (2026-10-15): mysqli vs PDO for PHP and MySQL: the API differences, prepared statements in both, the PHP 8.1 error default, real performance and moving off mysql_* code.
- [SSL certificate errors: what each one means and the fix](https://www.renode.space/blog/ssl-certificate-errors-explained) (2026-10-15): Fix browser certificate errors: ERR_CERT_DATE_INVALID, COMMON_NAME_INVALID, missing chain, self-signed, ERR_SSL_PROTOCOL_ERROR, and how to check with openssl.
- [WP-Cron vs real cron: fix missed WordPress schedules](https://www.renode.space/blog/wordpress-wp-cron-vs-real-cron) (2026-10-15): How WP-Cron fires on page loads, why scheduled posts get missed, and how to switch to a real cron with DISABLE_WP_CRON, curl or WP-CLI and debug it.
- [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.
- [WordPress vs Drupal vs Joomla: which CMS for which site](https://www.renode.space/blog/cms-hosting-wordpress-drupal-joomla-compared) (2026-10-08): WordPress, Drupal and Joomla compared honestly: what each is good at, PHP and database requirements, memory, updates and upkeep, and how to pick one for a site.
- [DataLife Engine (DLE) hosting: requirements and setup](https://www.renode.space/blog/dle-datalife-engine-hosting) (2026-10-08): Host DataLife Engine properly: PHP and MySQL requirements, the licence and demo, installation, dbconfig.php, friendly URLs on nginx, caching and updates.
- [Drupal hosting guide: requirements, Composer and settings](https://www.renode.space/blog/drupal-hosting-guide) (2026-10-08): Host Drupal 11 properly: PHP and database requirements, a Composer build without shell access, settings.php, nginx rules, cron, caching and updates.
- [Joomla hosting guide: install, configuration.php, updates](https://www.renode.space/blog/joomla-hosting-guide) (2026-10-08): Host Joomla 6 or 5.4: requirements, a manual install, configuration.php settings, SEF URLs on nginx, extensions, caching, scheduled tasks and safe updates.
- [WordPress object cache with Valkey: setup and limits](https://www.renode.space/blog/valkey-object-cache-for-wordpress) (2026-10-08): Add a persistent object cache to WordPress with Valkey: what it speeds up, what it does not, plugin setup, wp-config constants, sizing and troubleshooting.
- [WordPress media offload to S3: plugins, URLs and trade-offs](https://www.renode.space/blog/wordpress-media-offload-to-s3) (2026-10-08): Move WordPress uploads to S3-compatible storage: which offload plugins support a custom endpoint, how URLs and public access work, and when it is not worth it.
- [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.
- [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 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 Laravel to production: queues and cron](https://www.renode.space/blog/deploy-laravel-to-production) (2026-09-21): What a Laravel app needs on a real server: the .env and app key, writable storage, config and route caches, migrations, queue workers and the scheduler.
- [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.
- [How much RAM does WordPress actually need](https://www.renode.space/blog/how-much-ram-does-wordpress-need) (2026-09-21): Size WordPress by PHP workers, not by guesswork. Where the memory goes, what memory_limit really controls, and how caching changes every number.
- [HTTP caching headers: Cache-Control and ETag](https://www.renode.space/blog/http-caching-headers-explained) (2026-09-21): What every Cache-Control directive does, how ETags and 304s work, and the two-tier rule that makes a site fast without serving anybody stale HTML.
- [HTTPS and Let's Encrypt: how ACME works](https://www.renode.space/blog/https-and-lets-encrypt-explained) (2026-09-21): What a certificate proves, how the ACME challenge works, why renewal is automatic, and the handful of reasons issuance fails on a real domain.
- [Install WordPress manually: DB and wp-config](https://www.renode.space/blog/install-wordpress-manually) (2026-09-21): Download WordPress, create the database, write wp-config.php with real salts, set permissions and finish the install - and fix what usually breaks.
- [Migrate WordPress with zero downtime](https://www.renode.space/blog/migrate-wordpress-to-a-new-host) (2026-09-21): Copy the files, move the database, search-replace the URLs without breaking serialised data, test on a hosts file, then cut DNS over with a short TTL.
- [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.
- [php.ini settings that matter in production](https://www.renode.space/blog/php-ini-settings-that-matter) (2026-09-21): memory_limit, upload_max_filesize, max_execution_time, max_input_vars and OPcache: what each default costs you, and which file PHP is actually reading.
- [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.
- [Static site hosting: uploads and caching](https://www.renode.space/blog/static-site-hosting-guide) (2026-09-21): How to host a static or Jamstack site properly: which folder to upload, clean URLs, caching rules, redirects, and the parts a static site still cannot do.
- [TTFB and Core Web Vitals: what hosting changes](https://www.renode.space/blog/ttfb-core-web-vitals-and-hosting) (2026-09-21): Which part of LCP, INP and CLS your server controls, how to measure time to first byte properly, and the fixes that are worth money rather than effort.
- [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.
- [WooCommerce hosting: sizing a real shop](https://www.renode.space/blog/woocommerce-hosting-requirements) (2026-09-21): What a WooCommerce shop actually needs: PHP settings, RAM and CPU by order volume, why checkout cannot be cached, real cron, and the tables that grow.
- [WordPress security hardening worth doing](https://www.renode.space/blog/wordpress-security-hardening) (2026-09-21): Update policy, logins, file permissions, xmlrpc, plugin hygiene and backups - the WordPress hardening that stops real attacks, and the theatre to skip.
- [WordPress caching: what actually moves TTFB](https://www.renode.space/blog/wordpress-speed-and-caching) (2026-09-21): Page cache, OPcache, object cache and images: which WordPress optimisations change time to first byte, which change nothing, and how to measure both.
- [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.
- [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.
- [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.
- [Sizing a web app for launch day](https://www.renode.space/blog/sizing-a-web-app-for-launch-day) (2026-04-03): Launch traffic is short, spiky and mostly cacheable. Turn a visitor estimate into requests per second, size the workers, and fix what actually breaks.
- [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.

---

### Block HTTP/1.0 requests in nginx and Apache, safely

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/block-http-1-0-requests-nginx-apache

To block HTTP/1.0 in nginx, check `$server_protocol` and return early: `if ($server_protocol = HTTP/1.0) { return 444; }` inside the `server` block. In Apache, match `%{SERVER_PROTOCOL}` with mod_rewrite and answer `403`, or use an `<If>` block with `Require all denied`. Both take a minute to write. The part that takes longer is deciding whether you should, because the traffic you are aiming at (old bots, mass scanners, requests with no `Host` header) is mixed with a handful of legitimate clients that still speak HTTP/1.0 - including, very often, your own reverse proxy. Log first, block second, and in most cases a catch-all default server does most of the useful work without touching HTTP/1.0 at all.

#### Why HTTP/1.0 traffic is worth looking at

HTTP/1.0 was replaced by HTTP/1.1 in 1997. Every browser released this century speaks 1.1 at minimum, and over HTTPS they negotiate HTTP/2 or HTTP/3. So when an access log fills with `HTTP/1.0` requests, they are not coming from people reading your site. They come from three kinds of client:

- **Mass scanners and banner grabbers.** A tool sweeping the whole IPv4 space wants the shortest possible request that gets an answer. `GET / HTTP/1.0` followed by a blank line is exactly that: no `Host` header required, no keep-alive to manage, the server closes the connection when it is done.
- **Old or lazy bots.** Scrapers built on ancient HTTP libraries, vulnerability probes copied from decade-old exploit scripts, and home-made crawlers that hard-code the request line.
- **Machines talking to machines.** Load balancer health checks, benchmarking tools, monitoring scripts and - the one that catches people - reverse proxies talking to the server behind them.

The interesting property of HTTP/1.0 is that the `Host` header is optional. HTTP/1.1 makes it mandatory, and both nginx and Apache reject an HTTP/1.1 request without one with a `400 Bad Request`. An HTTP/1.0 request without `Host` is legal, so the server has to guess which site to serve, and it picks the default one for that address and port. That is how scanners discover what is running on an IP address without knowing any of its domain names: they ask without a name and read whatever the default site gives back - a CMS login page, a framework's error page, a stack version in a header.

> [!NOTE]
> Blocking HTTP/1.0 is housekeeping, not a security boundary. Any scanner can send `HTTP/1.1` and a made-up `Host` header in the same number of bytes, and the serious ones already do. What you get is a quieter log, fewer pointless hits on the application, and less to read when something real happens.

That is still worth having. A quieter log is easier to alert on, and an application that never sees junk requests never spends a database query on them. Just do not file it under "hardened".

#### Log before you block

Before changing any behaviour, find out who is actually sending HTTP/1.0 to you. The default `combined` log format in both servers already records it, because the request line - method, path and protocol - is one field.

```bash
# How many HTTP/1.0 requests, out of how many in total
$ grep -c 'HTTP/1.0"' /var/log/nginx/access.log
$ wc -l < /var/log/nginx/access.log

# Which user agents send them, most frequent first
$ grep 'HTTP/1.0"' /var/log/nginx/access.log \
    | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20

# Which addresses send them
$ grep 'HTTP/1.0"' /var/log/nginx/access.log \
    | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
```

The Apache equivalent is the same commands against `/var/log/apache2/access.log` on Debian and Ubuntu, or `/var/log/httpd/access_log` on RHEL-family systems. The `awk -F'"'` trick works because in the combined format the user agent is the third quoted field.

What you are looking for in the output:

- **Your own addresses.** `127.0.0.1`, a private range such as `10.0.0.0/8`, or the address of a load balancer. These are machines you run, and blocking them breaks something.
- **Recognisable tools.** `ApacheBench`, a monitoring product's name, `HAProxy` health checks, an uptime service. Each of these is somebody's job.
- **Empty or meaningless user agents** from a long tail of addresses, hitting `/`, `/.env`, `/wp-login.php`, `/phpmyadmin/` and similar. This is the traffic you want gone.

Let it run for a week rather than an afternoon. Weekly cron jobs, monthly reports and the payment provider that calls back twice a year all show up eventually, and a week is the shortest window that catches most of them. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) covers how long to retain access logs so that this kind of check is possible after the fact.

In nginx you can also write HTTP/1.0 requests to a separate file, which makes the week of watching painless. The `if=` parameter on `access_log` logs a request only when the variable is non-empty and not `0`:

/etc/nginx/conf.d/http10-log.conf:

```nginx
map $server_protocol $is_http10 {
    default     0;
    "HTTP/1.0"  1;
}
```

```nginx
server {
    # ... your existing configuration ...
    access_log /var/log/nginx/access.log combined;
    access_log /var/log/nginx/http10.log combined if=$is_http10;
}
```

Apache does the same with `SetEnvIf`, which can match on `Request_Protocol` directly, and a `CustomLog` that only fires when the variable is set:

/etc/apache2/conf-available/http10-log.conf:

```text
SetEnvIf Request_Protocol "^HTTP/1\.0$" old_http
CustomLog ${APACHE_LOG_DIR}/http10.log combined env=old_http
```

Enable it with `a2enconf http10-log` and reload. On RHEL-family systems, drop the file into `/etc/httpd/conf.d/` and replace `${APACHE_LOG_DIR}` with `logs`.

#### The catch-all default server first

Most of what people want from "block HTTP/1.0" is really "stop answering requests that do not name one of my sites". That has a cleaner fix that works for every protocol version: a default server that refuses everything, so only requests carrying a real hostname reach a real site.

```diagram
title: Where a request lands depends on its Host header
client = Client | any HTTP version
nginx = nginx | port 80 / 443
catch = Catch-all | default_server, 444
site = Your site | server_name matches
app = Application
client -> nginx
nginx -> catch : no Host or unknown name
nginx -> site : known Host
site -> app
```

In nginx, this is a `server` block marked `default_server` on each port. Requests without a `Host` header, and requests with one that matches no `server_name`, all land here:

/etc/nginx/sites-available/00-catchall.conf:

```nginx
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;
}
```

`444` is not a real HTTP status. It tells nginx to close the connection without sending anything, so the client learns nothing - not even which server software answered. On port 443 the TLS handshake happens before any HTTP request, so `return 444` alone would need a certificate to present first; `ssl_reject_handshake on` (nginx 1.19.4 and later) refuses the handshake for names you have not configured, with no certificate needed. On older nginx you have to give the catch-all a self-signed certificate. The [nginx reverse proxy guide](https://www.renode.space/blog/nginx-reverse-proxy-guide) covers the rest of the layout, including why only one block per port may carry `default_server`.

Apache has no `default_server` keyword. The first `VirtualHost` loaded for an address and port is the default, which is why Debian names its default site `000-default.conf`. Replace its contents with a vhost that denies everything:

/etc/apache2/sites-available/000-default.conf:

```text
<VirtualHost *:80>
    ServerName default.invalid
    <Location />
        Require all denied
    </Location>
    ErrorLog ${APACHE_LOG_DIR}/default-error.log
    CustomLog ${APACHE_LOG_DIR}/default-access.log combined
</VirtualHost>
```

Apache cannot drop a connection silently the way nginx can, so this returns `403 Forbidden`. The `.invalid` top-level domain is reserved and will never resolve, which guarantees no real request matches it by name.

With the catch-all in place, a Host-less HTTP/1.0 request already gets nothing useful. For many sites that is enough, and the remaining HTTP/1.0 traffic - requests that do name your domain - is harmless enough to leave alone. If you still want it gone, read on.

#### Blocking HTTP/1.0 in nginx

`$server_protocol` holds the protocol the client used: `HTTP/1.0`, `HTTP/1.1`, `HTTP/2.0` or `HTTP/3.0`. The simplest block compares it directly:

```nginx
server {
    listen 80;
    server_name example.com www.example.com;

    if ($server_protocol = HTTP/1.0) {
        return 444;
    }

    # ... the rest of the site ...
}
```

The usual warning about `if` in nginx being dangerous applies to `if` inside a `location` combined with other directives. An `if` at `server` level containing only `return` is one of the cases the nginx documentation describes as safe.

Match HTTP/1.0 positively. A rule written as `if ($server_protocol != HTTP/1.1)` looks equivalent and is not: it also blocks every HTTP/2 and HTTP/3 visitor, which on an HTTPS site is nearly all of them.

##### The map approach, with exceptions

A bare `if` blocks everybody. The moment you need an exception - your monitoring server, the load balancer, `127.0.0.1` - a `map` reads better and lives in one place for every site. `geo` marks trusted addresses, and `map` combines the two variables into one decision:

/etc/nginx/conf.d/http10-block.conf:

```nginx
geo $http10_trusted {
    default        0;
    127.0.0.1      1;
    10.0.0.0/8     1;
    203.0.113.25   1;   # monitoring host
}

map "$server_protocol:$http10_trusted" $http10_block {
    default          0;
    "HTTP/1.0:0"     1;
}
```

Then in each `server` block that should enforce it:

```nginx
if ($http10_block) {
    return 444;
}
```

Files in `conf.d` are included inside the `http` context, which is where `map` and `geo` have to live. Both are evaluated lazily, only when the variable is used, so defining them costs nothing on sites that do not reference them. To exempt a user agent rather than an address, add `$http_user_agent` to the map source and use regex keys, but remember that a user agent is whatever the client says it is.

##### 444, 426 or 403

Which response to send depends on who you expect to read it.

| Response | What the client sees | Use it when |
| --- | --- | --- |
| `444` | Connection closed, no bytes | The traffic is junk and owed nothing |
| `426` | Upgrade Required, with a reason | A real client might need telling |
| `403` | Forbidden | You want an ordinary, well-understood refusal |

`426 Upgrade Required` is the polite, standards-shaped answer: it says the request was understood and that a newer protocol is needed. The HTTP specification expects an `Upgrade` header to name the protocol, and in nginx `add_header` is allowed inside `if` only at `location` level, so the full version looks like this:

```nginx
location / {
    if ($http10_block) {
        add_header Upgrade "HTTP/1.1" always;
        add_header Connection "Upgrade" always;
        return 426 "HTTP/1.1 or newer is required.\n";
    }
    # ... proxy_pass, try_files, etc ...
}
```

`always` matters: without it, `add_header` only applies to a short list of success and redirect codes and the headers would silently vanish from the 426. In practice, `444` is the right choice for most sites. A scanner does not read status lines, and a legitimate client that gets an empty reply produces a clear error (`curl: (52) Empty reply from server`) that tells you exactly where to look.

#### Blocking HTTP/1.0 in Apache

Apache gives you three ways to do it. Pick one; mixing them makes the next person's debugging miserable.

##### mod_rewrite on SERVER_PROTOCOL or THE_REQUEST

This is the version most guides show, and it works in both the main configuration and `.htaccess`:

.htaccess or <VirtualHost>:

```text
RewriteEngine On
RewriteCond %{SERVER_PROTOCOL} =HTTP/1.0
RewriteRule ^ - [F]
```

`%{SERVER_PROTOCOL}` holds the protocol string, and the `=` prefix makes it an exact string comparison rather than a regex. `[F]` returns `403 Forbidden` and stops processing. To send `426` instead, use `RewriteRule ^ - [R=426,L]` - when the status given to `R` is outside the 3xx range, mod_rewrite drops the substitution and just returns that code.

`%{THE_REQUEST}` is the full original request line, such as `GET /index.php HTTP/1.0`. Matching on it is equivalent for this purpose:

```text
RewriteCond %{THE_REQUEST} HTTP/1\.0$
RewriteRule ^ - [F]
```

The difference matters elsewhere: `THE_REQUEST` is never changed by earlier rewrites or internal redirects, which is why it is the variable people reach for when blocking on the raw request. For the protocol check, `SERVER_PROTOCOL` reads more clearly. Exceptions are one more condition: `RewriteCond %{REMOTE_ADDR} !^10\.` placed before the rule skips the private range.

Inside `.htaccess`, mod_rewrite only works if the directory's `AllowOverride` includes `FileInfo` (or `All`). If the rules seem to do nothing, that is the first thing to check, followed by whether mod_rewrite is enabled at all (`a2enmod rewrite` on Debian).

##### Require with an expression, no mod_rewrite

Apache 2.4's expression parser knows `SERVER_PROTOCOL` too, so the same rule can be written with access control instead of rewriting:

<VirtualHost> or .htaccess:

```text
<If "%{SERVER_PROTOCOL} == 'HTTP/1.0' && ! -R '10.0.0.0/8'">
    Require all denied
</If>
```

`-R` tests whether the client address is inside a subnet. Alternatively, reuse the `old_http` variable from the logging section and write `Require not env old_http` inside a `<RequireAll>` block next to `Require all granted`. Both forms return `403`. This approach is easier to read than rewrite rules and does not interact with whatever rewriting your CMS already does, which on a WordPress or Laravel `.htaccess` is a real advantage.

##### What HttpProtocolOptions does and does not do

`HttpProtocolOptions` appears in almost every answer to this question, and it does not block HTTP/1.0. Added in Apache 2.4.24, it controls how strictly the request line and headers are parsed. Its default is `Strict LenientMethods Allow0.9`, and the option people reach for is:

apache2.conf or <VirtualHost>:

```text
HttpProtocolOptions Strict Require1.0
```

`Require1.0` rejects HTTP/0.9 - the pre-1996 form where the request is just `GET /` with no version and no headers. HTTP/1.0 and newer are still accepted. It is worth setting, since nothing legitimate has spoken 0.9 in decades, but it is a different fix for a different (and smaller) problem. `Strict` is already the default and makes Apache reject malformed request lines and headers with `400`; leave it on unless an old client forces you to use `Unsafe`.

#### Testing with curl

`curl` can speak HTTP/1.0 on request, which makes the checks quick. Run them from a machine that is not on your exception list.

```bash
# HTTP/1.0 with a Host header - should be blocked if you block 1.0
$ curl --http1.0 -sv -o /dev/null http://example.com/

# HTTP/1.0 with no Host header - should hit the catch-all
$ curl --http1.0 -sv -o /dev/null -H "Host:" http://203.0.113.10/

# HTTP/1.1, the control - should work normally
$ curl --http1.1 -sv -o /dev/null http://example.com/

# Over HTTPS, where browsers will negotiate HTTP/2
$ curl --http1.0 -sv -o /dev/null https://example.com/
```

`-H "Host:"` with nothing after the colon tells curl to remove the header it would otherwise add, which is the only way to make it send a Host-less request. What to expect:

- A `444` from nginx appears as `curl: (52) Empty reply from server`.
- A `403` or `426` appears as `< HTTP/1.1 403 Forbidden` or similar in the verbose output. Note that the server answers with its own highest version in the status line even when the request was 1.0; that is normal.
- An `HTTP/1.1` request without a Host header is rejected with `400` by both servers before your rules even run.

For the rawest possible test, with no client logic in the way, write the request yourself:

```bash
$ printf 'GET / HTTP/1.0\r\n\r\n' | nc example.com 80
```

After testing, check that your exceptions still work by running the first command from a trusted address, and confirm the logs show the blocked requests with the status you chose. Always run `nginx -t` or `apachectl configtest` before reloading - a typo in a `map` takes every site on the server down, not just the one you were editing.

#### Legitimate clients you might break

This is the section to read twice. Each of these still sends HTTP/1.0 by default or in common configurations:

- **nginx itself, as a proxy.** `proxy_http_version` defaults to `1.0`. If you have nginx in front of another nginx or an Apache, the backend sees every request as HTTP/1.0, and a protocol block on the backend blocks your entire site. Either set `proxy_http_version 1.1;` on the front proxy (which you want anyway, for keep-alive and websockets) or only enforce the rule on the outer server.
- **Load balancer health checks.** HAProxy's `option httpchk` sends an HTTP/1.0 request unless you give it a version. A blocked health check marks the backend down, and the load balancer takes the site offline for you.
- **ApacheBench.** `ab` speaks HTTP/1.0 only. Your load test will report every request as failed and you will spend an hour suspecting the application.
- **Monitoring and uptime checks.** Some older checks and plugins still default to 1.0. Look for them in the logging week rather than finding out when the alerts stop.
- **Very old command-line tools and embedded clients.** Old `wget` builds, printers, routers, IoT firmware and scripts written against ancient libraries. If one of these posts to a webhook on your site, it stops working silently.
- **Webhooks from older systems.** Payment, shipping and ERP integrations are where long-lived legacy code lives. They are rare, which is exactly why a single afternoon of logs misses them.

There is a subtler version of the first problem: **a proxy you do not control**. When a site sits behind a CDN, a hosting provider's front end or any other reverse proxy, the HTTP version you see is the one the proxy used to reach you, not the one the visitor used. On RE:NODE app and web plans, a domain attached through the plan's proxy slot works this way: the slot handles the certificate and passes the real client address in `X-Forwarded-For`. Check `$server_protocol` (or `%H` in Apache's log format) on your own server before writing a rule: if every request arrives with the same version, a protocol check at that layer either blocks nothing or blocks everything. On a [dedicated server](https://www.renode.space/dedicated-servers) where your nginx is the first thing on the internet, the version you log is the visitor's own.

> [!WARNING]
> If the front proxy and the backend are both yours, apply the HTTP/1.0 rule only on the outermost one. A rule copied onto the backend by a shared include file is the most common way this change takes a site down.

#### What this does not stop

Being clear about the limit saves disappointment. A client that sends HTTP/1.1 with a valid `Host` header passes straight through every rule in this post, and modern scanning frameworks, credential-stuffing tools and scrapers all do. The protocol check filters out the laziest layer of background noise; it does nothing about a determined attacker or a well-written bot.

For the traffic that gets past it, the tools are different. [Rate limiting](https://www.renode.space/blog/rate-limits-and-abuse) caps how fast any single client can hit expensive endpoints like login forms. [Fail2ban](https://www.renode.space/blog/fail2ban-guide) reads logs and bans addresses at the firewall after repeated bad requests - and the `http10.log` from the logging section makes a simple jail input if you want persistent offenders banned outright. [Firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter) covers closing every port that does not need to be open, which removes far more attack surface than anything done at the HTTP layer. For a WordPress site, [WordPress security hardening](https://www.renode.space/blog/wordpress-security-hardening) deals with the paths these scanners are usually looking for.

A sensible order of work, then: set up the catch-all default server, log HTTP/1.0 for a week, add exceptions for everything of yours that shows up, block with `444` in nginx or `403` in Apache, test with curl, and keep the separate log running so you notice the day something legitimate starts failing.

#### FAQ

##### Will blocking HTTP/1.0 affect search engine crawlers?

No. Googlebot, Bingbot and the other major crawlers use HTTP/1.1 or HTTP/2. If an obscure crawler you care about appears in your HTTP/1.0 log during the logging week, add an exception for it before blocking.

##### Should I use 444 or 403 in nginx?

`444` for traffic you are sure is junk, because it gives the client nothing - not even confirmation of what server answered. `403` or `426` when a real client might be affected and its owner needs a readable error to work out what changed.

##### Does HttpProtocolOptions Require1.0 block HTTP/1.0 in Apache?

No, despite the name. It requires a version of at least 1.0, which means it rejects HTTP/0.9. To refuse HTTP/1.0 itself, use a mod_rewrite condition on `%{SERVER_PROTOCOL}` or an `<If>` block with `Require all denied`.

##### Can I block requests without a Host header but allow HTTP/1.0 with one?

Yes, and it is often the better compromise. A catch-all `default_server` in nginx, or a first-loaded deny-all `VirtualHost` in Apache, receives every request that names no known site, including all Host-less ones. Requests that name your domain are unaffected, whatever their version.

##### Why did my whole site go down after I blocked HTTP/1.0?

Almost certainly because another proxy sits in front of the server you changed and talks to it over HTTP/1.0 - nginx does this by default. Remove the rule from the backend, apply it on the outer proxy, and set `proxy_http_version 1.1` there.

##### Does this help against a DDoS attack?

Not meaningfully. Flood traffic does not need HTTP/1.0, and by the time nginx closes a connection it has already been accepted. Volumetric attacks are dealt with upstream of the web server, not in its configuration.

A RE:NODE VDS runs in Germany on NVMe storage and is prepared by hand within 24 hours of payment. The nginx or Apache you put on it is the first thing on the internet, so catch-all defaults and protocol rules behave exactly as described here.

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

---

### How HTTPS works, in plain words

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/how-https-works-simply

HTTPS is ordinary HTTP - the same requests, the same pages - sent through an encrypted tunnel called TLS. Before a single byte of the page moves, your browser and the server agree on a secret key that nobody watching the network can work out, and the server proves with a certificate that it really is the site whose name you typed. After that, anyone between you and the server sees that you connected to a particular address and roughly how much data went each way. They do not see the pages, the forms, the passwords or the cookies, and they cannot change any of it without the connection breaking.

That is the whole idea. The rest of this post is how each part works, what the padlock does and does not promise, and why a site with nothing to hide still needs it.

#### HTTP vs HTTPS: what actually changes

Plain HTTP is text sent in the open. A request for a login page looks, on the wire, exactly like what the browser wrote:

```text
POST /login HTTP/1.1
Host: shop.example.com
Content-Type: application/x-www-form-urlencoded

email=anna%40example.com&password=herring-barrel-42
```

Every device that carries that packet - the cafe's router, the hotel's network box, the internet provider, any machine between them - can read it, keep it, or rewrite it. Rewriting is not hypothetical: providers and public hotspots have injected adverts, tracking scripts and "you are near your data cap" banners into plain HTTP pages for years.

HTTPS changes the transport and nothing above it:

| | HTTP | HTTPS |
| --- | --- | --- |
| Default port | `80` | `443` |
| URL scheme | `http://` | `https://` |
| On the wire | Readable text | Encrypted records |
| Server identity | Not checked | Proven by a certificate |
| Tampering | Invisible | Breaks the connection |

The application does not change. The same server code, the same HTML, the same cookies - TLS wraps the connection, and HTTP runs inside it unaware. That is also why "SSL" and "TLS" are used interchangeably: SSL was the original protocol from the 1990s, TLS is its successor, and every version of SSL has been formally retired as broken. What is called an "SSL certificate" today is used exclusively with TLS, and the current versions are TLS 1.2 and TLS 1.3. TLS 1.0 and 1.1 were deprecated in 2021 and modern browsers refuse them.

#### The two kinds of encryption it uses

HTTPS combines two families of cryptography, because each is good at what the other is bad at.

**Symmetric encryption** uses one key for both locking and unlocking. It is fast enough to encrypt video in real time on a phone. Its weakness is obvious: both sides need the same key, and you cannot simply send the key across the network that you are trying to protect. The ciphers used in practice are AES in GCM mode and ChaCha20-Poly1305; both also detect any change to the data, so tampering is caught, not just eavesdropping.

**Asymmetric (public-key) cryptography** uses a pair of keys. What one key does, only the other can verify or undo. One half is public and handed to anyone; the other is private and never leaves the server. This is slow, but it solves the two hard problems: two strangers can agree on a shared secret over an open line, and one of them can prove who it is without revealing anything that could be reused.

So HTTPS uses the slow, clever kind once at the start, to agree on a key and check identity, and the fast kind for everything after. The agreement step is a key exchange - in practice a variant of Diffie-Hellman over elliptic curves, such as X25519. Each side picks a random secret, sends a value derived from it, and both sides combine what they received with their own secret to arrive at the same number. An observer who saw both values in transit still cannot compute it.

A detail worth knowing: modern TLS throws those exchange secrets away after each connection. This is called forward secrecy, and it means that if the server's private key is stolen next year, traffic recorded today still cannot be decrypted. TLS 1.3 makes it mandatory; older setups that encrypted the session key directly with the server's certificate key did not have it.

#### Certificates and certificate authorities

Encryption alone is not enough. If an attacker sits between you and the server, they can run a perfectly encrypted connection with you and another with the real server, reading everything in the middle. The key exchange would succeed with the wrong party. What stops that is the certificate.

A certificate is a small file that binds a public key to one or more names. Its important fields:

- **Subject alternative names** - the hostnames it is valid for, such as `example.com` and `www.example.com`, or a wildcard like `*.example.com` that covers one level of subdomain.
- **Public key** - the public half of the key pair whose private half sits on the server.
- **Validity** - a "not before" and "not after" date. Outside that window the certificate is rejected.
- **Issuer and signature** - which authority vouches for this binding, and its digital signature over all of the above.

The authority is the point. A certificate authority (CA) is an organisation whose own public keys ship inside your operating system or browser, in a list called a root store. Mozilla, Apple, Microsoft and Google each maintain one and audit the CAs in it. When a CA signs a certificate, it is stating that it checked the requester controls the name. For the free certificates most sites use, that check is automated: the CA asks the requester to publish a token on the domain and fetches it - [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained) covers exactly how that challenge runs.

Roots are kept offline and rarely used, so the CA signs your certificate with an intermediate key, and the intermediate is signed by the root. The server sends its own certificate plus the intermediate; the browser already has the root. That is the chain:

```diagram
title: The browser trusts the root, and walks down
root = Root CA | built into the browser
inter = Intermediate CA | signed by the root
leaf = Your certificate | example.com
key = Server private key | never leaves the server
root -> inter : signs
inter -> leaf : signs
leaf --> key : matches
```

The browser checks each signature upwards until it reaches a root it already trusts. If any link is missing, expired, or signed by something unknown, the connection is refused with a warning. A server that sends only its own certificate and forgets the intermediate is the classic misconfiguration, and it is maddening because it works in some browsers - those that happen to have cached the intermediate from another site - and fails in others.

Every public certificate is also logged in Certificate Transparency logs, public append-only records that anyone can search. Browsers require proof that a certificate was logged. The result is that a CA cannot quietly issue a certificate for your domain to somebody else without leaving a trace you can find.

#### The TLS handshake, step by step

When you open `https://example.com`, this is what happens before the first byte of HTML arrives. The description is TLS 1.3, which almost every current browser and server negotiates; TLS 1.2 follows the same logic with one extra round trip.

1. **TCP connection.** The browser resolves the name through DNS and opens a connection to port `443` on that address. ([DNS records explained](https://www.renode.space/blog/dns-records-explained) covers the lookup.)
2. **ClientHello.** The browser sends the TLS versions and ciphers it supports, a random value, its half of the key exchange (a "key share"), and two extensions that matter: SNI, the hostname it wants, so a server hosting many sites knows which certificate to present; and ALPN, the protocols it can speak, such as `h2` for HTTP/2.
3. **ServerHello.** The server picks a version and cipher and sends its own key share. At this moment both sides can compute the same secret, and everything from here on is encrypted.
4. **Certificate.** The server sends its certificate and the intermediate chain.
5. **CertificateVerify.** The server signs a hash of the whole conversation so far with its private key. This is the proof of identity: only the holder of the private key matching the certificate could produce that signature, and because it covers this conversation's random values, it cannot be replayed from an old one.
6. **Finished.** The server sends a short check value over the whole handshake, proving nothing was altered in transit.
7. **Client checks.** The browser verifies the chain up to a trusted root, the dates, that the name in the address bar is in the certificate's list of names, and the signature in step 5. Any failure stops the connection here, with a warning page.
8. **Client Finished, then HTTP.** The browser sends its own Finished message, followed immediately by the ordinary `GET` request - now inside encrypted records.

```diagram
title: A TLS 1.3 handshake in one round trip
browser = Browser | ClientHello + key share
server = Server | ServerHello + key share
cert = Certificate | chain + signature
check = Browser checks | chain, name, dates
http = Encrypted HTTP | GET / and the page
browser -> server : versions, SNI, ALPN
server -> cert : from here, encrypted
cert -> check : Finished
check -> http : client Finished
```

The whole thing costs one network round trip before the request can go out - about the same as one ping to the server. A returning visitor can resume a previous session and skip the certificate step, and TLS 1.3 even allows sending data in the very first packet on resumption ("0-RTT"), at the cost of that early data being replayable, which is why servers only accept it for requests that are safe to repeat.

You can watch a real handshake from a terminal:

```bash
# Summary: protocol, cipher, certificate subject and issuer
$ openssl s_client -connect example.com:443 -servername example.com -brief < /dev/null

# The same from curl, with every TLS step printed
$ curl -v https://example.com -o /dev/null
```

The `-servername` flag sets SNI. Without it, a server hosting several names may hand back a default certificate for some other site, and the result looks like a broken certificate when it is really a missing flag.

HTTP/3 changes the transport underneath - it runs over QUIC on UDP port `443` instead of TCP - but the TLS 1.3 handshake is built into QUIC, and the steps above still happen.

#### What the padlock means, and what it does not

The padlock, or in current Chrome a neutral settings icon in the same place, means exactly three things: the connection is encrypted, it has not been altered in transit, and the server proved it holds a valid certificate for the name in the address bar. That is a statement about the pipe, not about the people at the other end.

It does **not** mean:

- **The site is honest.** A phishing page at `paypa1-secure-login.com` gets a valid certificate in seconds, because the certificate only proves control of that exact name. Read the name, not the icon.
- **The company is who it says.** The free, common certificates are domain-validated. More expensive organisation-validated and extended-validation certificates involve checking company records, but browsers no longer display the company name in the address bar, so a visitor cannot tell them apart.
- **The site is secure.** HTTPS protects data in transit. It does nothing about an outdated plugin, a weak admin password or a database left open to the internet. [WordPress security hardening](https://www.renode.space/blog/wordpress-security-hardening) is a better guide to that side.
- **Nobody can see you visited.** An observer on the network still sees the server's IP address, and usually the hostname too: SNI in the ClientHello is sent unencrypted unless both browser and server support Encrypted Client Hello, which is still rolling out, and the DNS lookup is plain text unless the device uses encrypted DNS. What they cannot see is the path, the query string, headers, cookies or content. `https://example.com/account/orders?id=4411` leaks `example.com`; the rest stays inside the tunnel.
- **Your employer or school cannot read it.** Corporate networks and some antivirus products install their own root certificate on managed devices and decrypt traffic in the middle on purpose. On a device you do not control, assume this is possible. [What a VPN protects and what it does not](https://www.renode.space/blog/what-a-vpn-protects-and-what-it-does-not) covers the overlap with VPNs.

> [!NOTE]
> The certificate warning page is the useful part of HTTPS. It is the browser telling you the identity check failed. Clicking through it throws away the protection that the padlock was supposed to give, so do it only on a device and network you understand, such as your own test server.

#### HSTS: closing the first-request gap

Even a site that redirects every HTTP request to HTTPS has a weak moment: the very first request. If you type `example.com`, the browser tries `http://` first, and that plain request can be intercepted before the redirect arrives. An attacker on the same wifi can answer it themselves and keep you on HTTP indefinitely - a downgrade attack.

HSTS (HTTP Strict Transport Security) closes that gap. The server sends a header once over HTTPS:

```text
Strict-Transport-Security: max-age=31536000; includeSubDomains
```

From then on, for `max-age` seconds - here, a year - the browser rewrites every `http://` link to that name to `https://` before sending anything, and it removes the option to click through a certificate warning. The first visit is still unprotected, which is what the preload list is for: names submitted to it are hard-coded into browsers as HTTPS-only before the first visit ever happens. Some whole top-level domains, such as `.dev` and `.app`, are on that list, so every site under them must serve HTTPS from day one.

HSTS is the one HTTPS feature that can lock you out of your own site if a subdomain still needs plain HTTP, and preloading is slow to undo. Start with a short `max-age`, confirm everything works, then raise it. The configuration, along with the redirect and mixed-content clean-up that should come first, is in [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained).

#### Why every site needs HTTPS

"My site has no logins and no payments" is the usual argument against bothering. It no longer holds, for reasons that have nothing to do with secrecy.

- **Browsers mark plain HTTP as not secure**, in the address bar, on every page. Forms on HTTP pages get an extra warning when someone starts typing, and browsers increasingly try HTTPS first and warn before falling back to plain HTTP.
- **Tampering affects boring pages too.** An injected script on a static recipe blog runs in the visitor's browser with the same power as yours: it can redirect, mine, or show a fake login prompt.
- **Modern features require it.** Geolocation, camera and microphone access, service workers, push notifications, the clipboard API and HTTP/2 in browsers all need a secure context. Browsers only use HTTP/2 and HTTP/3 over TLS, so an HTTP-only site is also stuck on the older, slower protocol.
- **Search engines prefer it.** Google has used HTTPS as a ranking signal since 2014. It is a small signal, but it costs nothing.
- **Referrer data survives.** When a visitor clicks from an HTTPS page to an HTTP one, the browser strips the referrer, so HTTP sites lose part of their analytics.
- **The certificate is free and renews itself.** The cost argument disappeared when free, automated certificates became the norm.

On RE:NODE's app and web plans the work is reduced to one DNS record: add the hostname to the proxy slot, point an A record at the address it shows, and the certificate is issued once the name resolves and renewed automatically inside a 21-day window. The proxy terminates TLS and passes plain HTTP to your application, with the visitor's real address in `X-Forwarded-For`. [Point a domain at your server and add SSL](https://www.renode.space/blog/your-domain-and-its-certificate) walks through that setup and the reasons issuance fails.

#### Certificate errors and what they mean

Most HTTPS problems surface as one of a handful of browser errors. The codes differ between browsers; the causes are the same.

| Chrome / Edge | Firefox | Cause |
| --- | --- | --- |
| `NET::ERR_CERT_DATE_INVALID` | `SEC_ERROR_EXPIRED_CERTIFICATE` | Expired, or your clock is wrong |
| `NET::ERR_CERT_COMMON_NAME_INVALID` | `SSL_ERROR_BAD_CERT_DOMAIN` | Name not on the certificate |
| `NET::ERR_CERT_AUTHORITY_INVALID` | `SEC_ERROR_UNKNOWN_ISSUER` | Chain incomplete or untrusted |

**Expired.** Either the site's renewal failed, or the visitor's device clock is wrong. A phone or PC set to a date years in the past rejects every certificate on the internet at once, which is the giveaway: if every site breaks, check the clock.

**Wrong name.** The certificate covers `example.com` but the visitor came via `www.example.com`, or a new subdomain was pointed at a server before a certificate was issued for it. A certificate is only valid for the names listed in it.

**Unknown issuer.** The server is not sending the intermediate certificate, or the certificate is self-signed. Test from a machine that has never visited the site, or use the `openssl` command above, which shows the chain the server actually sends.

**Not secure, with a valid certificate.** Mixed content: the page is HTTPS, but it loads a script, stylesheet or image over `http://`. Browsers block active content outright and the page half-renders. The browser console names the exact URL.

**`ERR_TOO_MANY_REDIRECTS`.** Usually an application behind a TLS-terminating proxy that sees plain HTTP on its own port and keeps redirecting to HTTPS. The app needs to trust the proxy's `X-Forwarded-Proto` header instead. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains why.

To check how a site behaves from outside, two commands answer most questions:

```bash
# Does HTTP redirect to HTTPS, and to which host?
$ curl -sI http://example.com | grep -iE "^(HTTP|location)"

# Is HSTS set, and for how long?
$ curl -sI https://example.com | grep -i strict-transport-security
```

#### FAQ

##### Is HTTPS the same as SSL?

In everyday speech, yes. Technically HTTPS is HTTP carried over TLS, and TLS replaced SSL in 1999. Every SSL version is now retired as insecure, but the old name stuck, which is why certificates are still sold as "SSL certificates". They are used with TLS 1.2 and 1.3.

##### Does HTTPS slow a site down?

Not measurably on modern hardware. The handshake adds one round trip on a new connection and less on a resumed one, and the encryption itself runs in dedicated processor instructions. In practice HTTPS sites are often faster, because browsers only use HTTP/2 and HTTP/3 over TLS.

##### Can my internet provider see which sites I visit over HTTPS?

They can see the IP addresses you connect to and, in most cases, the hostname - from your DNS lookups and from the SNI field in the handshake. They cannot see which pages you opened, what you searched for on the site, or anything you typed. Encrypted DNS and Encrypted Client Hello narrow this further where both ends support them.

##### How long does a certificate last?

Free certificates from Let's Encrypt last 90 days and are renewed automatically. The industry maximum for any publicly trusted certificate has been cut repeatedly and is scheduled to keep falling over the next few years, which is why manual yearly renewal is on its way out everywhere.

##### Is a self-signed certificate good enough?

For encryption on a private test server, yes. For a public site, no: no browser trusts it, so every visitor gets a full-page warning, and teaching people to click through warnings defeats the identity check that makes HTTPS work.

##### Do I need HTTPS for a game server?

Not for the game traffic itself - Minecraft, Valheim and most games use their own protocols on their own ports, not HTTP. You need it for anything web-facing around the game: a website, a map viewer, a donation page or a panel.

App and web plans on RE:NODE include a reverse-proxy slot: point an A record at the address it shows and the certificate is issued and renewed automatically inside a 21-day window, with TLS terminated before traffic reaches your code. Web plans start from $3 a month.

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

---

### mysqli vs PDO in PHP: differences, errors and which to pick

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/mysqli-vs-pdo

Pick PDO unless you have a specific reason not to. Both extensions are maintained, both talk to MySQL through the same `mysqlnd` library underneath, both are safe with prepared statements, and the speed difference between them is too small to measure in a real application. PDO wins on the things you touch every day: named placeholders, one API for MySQL, PostgreSQL and SQLite, fetch modes that hydrate rows into arrays or objects without a loop, and exceptions on by default since PHP 8.0. mysqli wins only where you need something MySQL-specific that PDO does not expose - asynchronous queries, `multi_query()` with its separate result sets, or a codebase already written in it that works. The rest of this post is the detail behind that verdict: how the two APIs actually differ, prepared statements side by side, the error-reporting change in PHP 8.1 that broke a lot of old mysqli code, what "performance" means here, and how to move legacy `mysql_*` code onto either.

#### The short version, as a table

| Question | mysqli | PDO |
| --- | --- | --- |
| Databases | MySQL and MariaDB only | A dozen drivers, one API |
| Placeholders | `?` only | `?` and `:named` |
| Style | Object or procedural | Object only |
| Prepares | Always server-side | Emulated by default for MySQL |
| Errors by default | Exceptions since PHP 8.1 | Exceptions since PHP 8.0 |
| Async queries | Yes, `MYSQLI_ASYNC` | No |
| Fetch into a class | `fetch_object()` | `FETCH_CLASS`, `fetchObject()` |

None of those rows is a reason to rewrite working code. They are reasons to choose one for new code, and the choice for new code is PDO in nearly every case. If you are already using PDO and want the full treatment of the DSN, options, transactions and error codes, [PHP PDO and MySQL](https://www.renode.space/blog/php-pdo-and-mysql) covers it; this post stays on the comparison.

#### What the two extensions are

Three MySQL extensions have existed in PHP, and the history explains the naming.

The original `mysql` extension - `mysql_connect()`, `mysql_query()`, `mysql_fetch_assoc()` - was deprecated in PHP 5.5 and removed entirely in PHP 7.0. It had no prepared statements, kept an implicit "last connection" in global state, and pushed a generation of developers into building SQL by concatenating strings. If a codebase still calls `mysql_query()`, it is running on PHP 5.6 or older, or it is not running at all.

`mysqli` (the "i" is for "improved") arrived in PHP 5 as the replacement. It is a fairly thin wrapper over MySQL's own client API: it exposes MySQL concepts directly, supports prepared statements, transactions, multiple statements and asynchronous queries, and offers both an object interface (`$mysqli->query()`) and a procedural one (`mysqli_query($link, ...)`). The procedural interface exists mainly to make porting old `mysql_*` code easier.

PDO (PHP Data Objects) arrived around the same time with a different goal: one database access API, with per-database drivers plugged in underneath. `pdo_mysql` is the MySQL driver. PDO does not try to expose everything MySQL can do. It exposes what most applications need, consistently, across databases.

Both extensions use `mysqlnd`, the MySQL native driver written for PHP, in every mainstream build. That matters for the performance question later: the network protocol, the buffering and the result handling are the same code in both cases.

#### Drivers: one database or many

PDO's driver list is the clearest functional difference. The common ones:

| Driver | Database |
| --- | --- |
| `pdo_mysql` | MySQL, MariaDB |
| `pdo_pgsql` | PostgreSQL |
| `pdo_sqlite` | SQLite |
| `pdo_sqlsrv` | SQL Server (Microsoft's extension, installed separately) |
| `pdo_odbc` | Anything with an ODBC driver |

You can check what a PHP install has with `php -m`, or `php -r 'print_r(PDO::getAvailableDrivers());'`.

The honest version of the portability argument: PDO does not make SQL portable. `LIMIT` versus `TOP`, `AUTO_INCREMENT` versus identity columns, backtick quoting versus double quotes - all of that is still yours to deal with. What PDO makes portable is the code around the SQL: connecting, binding, fetching, transactions and error handling. In practice that is most of the database code in an application, and it is why every major PHP framework and ORM - Laravel's Eloquent, Doctrine DBAL, CakePHP's ORM - is built on PDO. It is also why running the test suite against SQLite while production runs MySQL is a realistic option with PDO and a non-starter with mysqli.

If an application will only ever talk to MySQL, portability is worth little on its own. The case for PDO then rests on the API being pleasanter, which it is, mostly because of placeholders and fetch modes.

#### Prepared statements in both

The same query - find active users created after a date - written three ways.

mysqli, the classic form. Parameters are bound by reference with a type string: `i` integer, `d` double, `s` string, `b` blob sent in packets.

```php
$stmt = $mysqli->prepare(
    'SELECT id, email FROM users WHERE status = ? AND created_at > ?'
);
$stmt->bind_param('ss', $status, $since);
$stmt->execute();
$result = $stmt->get_result();
$users = $result->fetch_all(MYSQLI_ASSOC);
```

mysqli on PHP 8.1 and later can skip `bind_param()` and pass an array to `execute()`; every value is then bound as a string, which MySQL converts as needed. PHP 8.2 added `execute_query()`, which prepares, binds and executes in one call and returns a result:

```php
// PHP 8.1+
$stmt = $mysqli->prepare('SELECT id, email FROM users WHERE status = ? AND created_at > ?');
$stmt->execute([$status, $since]);

// PHP 8.2+
$users = $mysqli->execute_query(
    'SELECT id, email FROM users WHERE status = ? AND created_at > ?',
    [$status, $since]
)->fetch_all(MYSQLI_ASSOC);
```

PDO, with named placeholders:

```php
$stmt = $pdo->prepare(
    'SELECT id, email FROM users WHERE status = :status AND created_at > :since'
);
$stmt->execute(['status' => $status, 'since' => $since]);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
```

The differences that bite in practice:

- **Named placeholders.** PDO has them; mysqli does not. On a query with eight parameters, `:email` is readable and a type string of `ssisdssi` is a bug waiting for someone to add a column in the middle.
- **Binding by reference.** `bind_param()` binds variables, not values, and reads them at `execute()` time. That is useful in a loop (bind once, change the variables, execute repeatedly) and confusing everywhere else, because you cannot pass a literal or a function result directly. PDO has both `bindParam()` (by reference) and `bindValue()` (by value), and `execute($array)` covers most cases.
- **`get_result()` versus `bind_result()`.** `get_result()` returns a normal result object you can fetch arrays from. It needs `mysqlnd`, which is why old tutorials written for builds without it use `bind_result()` to bind each output column to a variable. On any current PHP you can use `get_result()` and forget `bind_result()` exists.
- **Server-side or emulated.** mysqli prepares are always real server-side prepared statements. PDO with MySQL emulates them by default - it escapes values and sends one finished query - unless you set `PDO::ATTR_EMULATE_PREPARES` to `false`. Both are safe when the connection character set is set properly. The PDO post explains when emulation matters.

Neither API lets a placeholder stand in for a table name, a column name or `ASC`/`DESC`. Those must come from a fixed list in your code, whichever extension you use.

#### Error reporting: what changed in PHP 8.0 and 8.1

This is the difference most likely to break an upgrade, and it changed in both extensions within a year.

**PDO** defaulted to `PDO::ERRMODE_SILENT` until PHP 8.0. A failed query returned `false`, set an error code you had to ask for, and the script carried on. PHP 8.0 changed the default to `PDO::ERRMODE_EXCEPTION`, so a failed query throws `PDOException`.

**mysqli** followed in PHP 8.1. Before 8.1 the default report mode was `MYSQLI_REPORT_OFF`: `query()` returned `false`, and you checked `$mysqli->error` or `$mysqli->errno` yourself - or, as most code did, did not check at all. From PHP 8.1 the default is `MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT`, which throws `mysqli_sql_exception` on any error, including a failed connection.

Old mysqli code typically looks like this:

```php
$link = mysqli_connect($host, $user, $pass, $db);
if (!$link) {
    die('Connect failed: ' . mysqli_connect_error());
}
$result = mysqli_query($link, $sql);
if ($result === false) {
    error_log(mysqli_error($link));
    // fall back to something
}
```

On PHP 8.1 the `if` blocks never run: the exception is thrown first, and if nothing catches it, the request dies with an uncaught `mysqli_sql_exception`. With `display_errors` on - which it should not be in production - that message can include the host name and user. Code that relied on a failed `INSERT` returning `false` so it could show "that name is taken" now crashes instead.

You have two ways forward. The right one is to keep exceptions and catch the errors you expect:

```php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); // explicit, same as 8.1 default

try {
    $mysqli->execute_query('INSERT INTO users (email) VALUES (?)', [$email]);
} catch (mysqli_sql_exception $e) {
    if ($e->getCode() === 1062) {   // duplicate entry
        return 'That email is already registered.';
    }
    throw $e;
}
```

The stopgap is to put the old behaviour back with `mysqli_report(MYSQLI_REPORT_OFF);` right after startup. It buys time on a large legacy codebase, but it also brings back the silent failures, so treat it as a temporary setting with a ticket attached.

> [!WARNING] Do not use `MYSQLI_REPORT_ALL`. It includes `MYSQLI_REPORT_INDEX`, which reports every query that does not use an index - and with strict mode, that means throwing an exception on a perfectly valid query against a small table.

One small difference in what you branch on: `mysqli_sql_exception::getCode()` returns the MySQL error number (`1062`, `1045`, `2002`), and `getSqlState()` returns the SQLSTATE. `PDOException::getCode()` returns the SQLSTATE string (`23000`), and the MySQL number lives in `$e->errorInfo[1]`. Branch on the MySQL number in both; one SQLSTATE covers many unrelated errors. [MySQL users and privileges](https://www.renode.space/blog/mysql-users-and-privileges) explains the common `1045 Access denied` cases.

#### Types, fetching and the features only one has

##### Integers that come back as strings

With mysqli, a plain `query()` uses MySQL's text protocol and returns every column as a string, even `INT` columns. Prepared statements use the binary protocol and return native integers and floats. You can make plain queries return native types too:

```php
$mysqli = mysqli_init();
$mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true);
$mysqli->real_connect($host, $user, $pass, $db, $port);
```

PDO with native prepares returns integers and floats; with emulated prepares it returned strings until PHP 8.1, which changed it to native types. In both extensions `DECIMAL` comes back as a string on purpose, so money does not pick up floating-point rounding. When an upgrade suddenly breaks a strict comparison like `$row['id'] === '5'`, this is usually why.

##### Fetching

PDO's fetch modes save loops: `FETCH_COLUMN` for a flat list, `FETCH_KEY_PAIR` for an id-to-name map, `FETCH_GROUP` for rows grouped by the first column, `FETCH_CLASS` to hydrate objects. mysqli has `fetch_assoc()`, `fetch_row()`, `fetch_object()`, `fetch_all()` and, since PHP 8.1, `fetch_column()`. Anything beyond that is a loop you write.

A `mysqli_result` is iterable, so `foreach ($result as $row)` gives associative arrays without calling `fetch_assoc()` in a `while` loop. A PDO statement is iterable the same way, in whatever fetch mode is set.

##### What only mysqli does

- **Asynchronous queries.** `$mysqli->query($sql, MYSQLI_ASYNC)` returns immediately; `mysqli_poll()` waits on several connections and `reap_async_query()` collects the result. This lets one PHP process run a few slow queries in parallel on separate connections. It is rarely used, and when it is, it is usually for reports that fan out over several databases.
- **`multi_query()`.** Runs several semicolon-separated statements in one call and walks the results with `next_result()`. Useful for running a migration file. It is also exactly what an SQL injection wants, so it should never see user input. PDO with emulated prepares can run multiple statements in one `exec()`, but handling their results is awkward.
- **MySQL-specific calls** such as `$mysqli->info` (rows matched and changed after an `UPDATE`), `stat()`, `thread_id` and `kill()`.

##### What only PDO does

Named placeholders, the fetch modes above, `FETCH_CLASS` with constructor arguments, and the same code on several databases. Since PHP 8.4 there are also driver subclasses: `PDO::connect()` returns a `Pdo\Mysql` object for a MySQL DSN, with the MySQL-specific constants on it.

#### Performance: what actually differs

Benchmarks of mysqli against PDO tend to show mysqli a few percent faster on tight loops of trivial queries. That number is real and irrelevant. Both extensions hand the work to `mysqlnd`; the difference is a thin layer of PHP-side wrapping per call, measured in microseconds. A single query that reads from disk instead of the buffer pool costs more than the gap across thousands of calls.

The things that do change how fast your database code runs:

- **Round trips.** A server-side prepared statement is a prepare request and then an execute request: two trips to the server before results come back. mysqli always does that for `prepare()` and `execute_query()`. PDO with emulated prepares sends one query. Over a local socket nobody notices; over a network path of a couple of milliseconds, a page that runs 200 one-off prepared queries spends real time waiting. Either fix the page so it does not run 200 queries, or prepare once and execute in a loop, which both extensions do well.
- **Buffered versus unbuffered results.** Both buffer the full result in PHP memory by default. For a large export, mysqli's `MYSQLI_USE_RESULT` (or `real_query()` plus `use_result()`) and PDO's `MYSQL_ATTR_USE_BUFFERED_QUERY => false` stream rows instead. This changes memory use by orders of magnitude; the extension choice does not.
- **Connections.** Opening a connection per helper function instead of once per request multiplies handshakes. That costs the same in both. [MySQL connection limits and pooling](https://www.renode.space/blog/mysql-connection-limits-and-pooling) covers why PHP-FPM workers and `max_connections` need to agree.
- **Indexes.** Nearly every "PHP is slow with MySQL" report is a missing index. [MySQL indexes and EXPLAIN](https://www.renode.space/blog/mysql-indexes-and-explain) is the place to start, and the [slow query log](https://www.renode.space/blog/mysql-slow-query-log) tells you which query to start with.

If you are choosing between the two extensions for speed, you are optimising the wrong layer.

#### Migrating legacy mysql_* code

Code still built on `mysql_*` functions has to move before it can run on any supported PHP. The mechanical route is the procedural mysqli API, because the function names line up; the better route, if you are touching every query anyway, is PDO with prepared statements. Either way, the work is the same three steps: replace the calls, pass the connection explicitly, and replace string-built SQL with placeholders.

The mapping for the common calls:

| `mysql_*` | mysqli (procedural) | PDO |
| --- | --- | --- |
| `mysql_connect()` + `mysql_select_db()` | `mysqli_connect($h, $u, $p, $db)` | `new PDO($dsn, $u, $p)` |
| `mysql_query($sql)` | `mysqli_query($link, $sql)` | `$pdo->query($sql)` |
| `mysql_fetch_assoc($r)` | `mysqli_fetch_assoc($r)` | `$stmt->fetch()` |
| `mysql_num_rows($r)` | `mysqli_num_rows($r)` | `count($stmt->fetchAll())` |
| `mysql_insert_id()` | `mysqli_insert_id($link)` | `$pdo->lastInsertId()` |
| `mysql_real_escape_string($s)` | prepared statement | prepared statement |
| `mysql_error()` | exception (8.1+) | exception |

The traps in a straight find-and-replace:

1. **Argument order.** `mysql_query($sql, $link)` took the link second and optionally. `mysqli_query($link, $sql)` takes it first and always. A regex that renames the function without swapping the arguments produces code that fails on every call.
2. **The implicit connection.** `mysql_*` functions used the last connection opened if you passed none, so legacy code calls `mysql_query()` from deep inside functions that never saw a connection variable. mysqli and PDO need it passed in. The quick fix is a function that returns a shared connection (`db()->query(...)`); a global variable works too and is worse.
3. **Functions with no equivalent.** `mysql_result()` (fetch one cell by row and field) has no mysqli twin; replace it with a fetch and an array index, or PDO's `fetchColumn()`. `mysql_list_dbs()` and friends become `SHOW DATABASES` queries.
4. **Escaping without a charset.** If the old code calls `mysql_real_escape_string()` and you swap it for `mysqli_real_escape_string()`, set the charset first with `mysqli_set_charset($link, 'utf8mb4')`, never with a `SET NAMES` query - escaping only knows the charset it was told through the API. Better still, delete the escaping and use placeholders.
5. **Error handling flips.** Legacy `or die(mysql_error())` patterns become dead code under PHP 8.1 exceptions. Decide on one error strategy at the start of the port rather than discovering it query by query.

Shim libraries exist that reimplement the `mysql_*` functions on top of mysqli so old code runs unchanged on PHP 7 and 8. They are a way to get an abandoned application onto a supported PHP version while you plan the real migration, not a destination: the code is still building SQL from strings.

> [!TIP] Port one data-access file at a time and keep both connections open during the transition. Two connections to the same database cost nothing, and they let you ship the migration in pieces instead of one large risky change.

Old code usually comes with an old database. MySQL 8.4 no longer enables the `mysql_native_password` plugin by default, and very old PHP builds cannot authenticate with its replacement, `caching_sha2_password`, so a legacy app moved onto a new database can fail at connect with an authentication error. [MySQL 8.4 LTS: what changed](https://www.renode.space/blog/mysql-8-4-lts-what-changed) covers the fix, and [migrating MySQL to a new host](https://www.renode.space/blog/migrating-mysql-to-a-new-host) covers moving the data.

#### Connecting to a hosted database from either

The connection rules are the same in both extensions, because the client library underneath is the same.

```php
// mysqli
$mysqli = new mysqli($host, $user, $pass, $db, (int) $port);
$mysqli->set_charset('utf8mb4');

// PDO
$pdo = new PDO(
    "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4",
    $user,
    $pass,
    [PDO::ATTR_EMULATE_PREPARES => false]
);
```

Three things hold for both. Set `utf8mb4` through the API - `set_charset()` or the DSN - so escaping and emoji both work ([MySQL utf8mb4 and collations](https://www.renode.space/blog/mysql-utf8mb4-and-collations) covers the tables). Do not write `localhost` for a database on another machine, because the client treats `localhost` as a request for a local Unix socket and ignores the port. And keep the credentials in environment variables rather than in a PHP file inside the web root; [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) explains where they belong.

Persistent connections exist in both - a `p:` prefix on the mysqli host, `PDO::ATTR_PERSISTENT` in PDO - and the same caution applies: they carry session state from one request to the next on the same worker, and they rarely save enough to be worth it.

On RE:NODE, a [MySQL 8.4 LTS database](https://www.renode.space/database-hosting#mysql) is created with a root password, an application database and an application user already generated, and it is reached on the plan's host and port - so either connection above is those four values pasted in. The [Laravel / PHP app line](https://www.renode.space/app-hosting#laravel-php) runs PHP 8.3, 8.4 or 8.5, which means every default described in this post (exceptions in both extensions, `execute_query()`, native types from emulated PDO prepares) is the behaviour you will see there.

#### FAQ

##### Is mysqli deprecated?

No. mysqli is maintained and gets new features - `execute_query()` arrived in PHP 8.2. What was deprecated and removed is the old `mysql` extension without the "i". Choosing PDO for new code is a preference, not a migration you are forced into.

##### Is mysqli less secure than PDO?

No. Both are equally safe when every outside value goes through a placeholder, and equally unsafe when SQL is built by concatenation. mysqli's prepares are always server-side, so there is no emulation layer to configure. The security of either depends on the habits around it, not the extension.

##### Can I use mysqli and PDO in the same application?

Yes. They are separate connections, so they do not share transactions, `lastInsertId()` or session variables, but they can both read and write the same database. That is the usual way to migrate gradually from one to the other.

##### Does PDO work with MariaDB?

Yes. `pdo_mysql` and mysqli both speak the MySQL protocol, which MariaDB implements. The differences you meet are in SQL features and server defaults, not in the PHP extension.

##### Why does my mysqli code throw exceptions after upgrading PHP?

PHP 8.1 changed mysqli's default report mode from off to `MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT`. Queries that used to return `false` quietly now throw `mysqli_sql_exception`. Catch the errors you expect, or call `mysqli_report(MYSQLI_REPORT_OFF)` as a short-term stopgap.

##### Which one do frameworks use?

PDO. Laravel, Symfony through Doctrine DBAL, CakePHP and most other frameworks build their database layer on it, which is one more reason new code that may end up inside a framework should start there.

A MySQL 8.4 LTS server on RE:NODE comes with a root password, an application database and an application user generated for you, reached on the plan's host and port - ready for a mysqli or PDO connection from any PHP host.

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

---

### SSL certificate errors: what each one means and the fix

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/ssl-certificate-errors-explained

Every browser certificate warning is one of about six problems, and the error code tells you which. `NET::ERR_CERT_DATE_INVALID` means the certificate is expired or the visitor's clock is wrong. `NET::ERR_CERT_COMMON_NAME_INVALID` means the certificate is for a different name. `NET::ERR_CERT_AUTHORITY_INVALID` means the browser cannot trace it back to an authority it trusts - a self-signed certificate, or a server that forgot to send its intermediate. `ERR_SSL_PROTOCOL_ERROR` means the two sides never got as far as looking at a certificate at all. And a padlock with "Not secure" next to a perfectly valid certificate is mixed content. Read the code, run one `openssl` command from a machine outside your network, and the fix is usually a single line of configuration.

This post is the troubleshooting half of the subject. How certificates are issued and renewed is in [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained), and pointing a domain at a server so a certificate can be issued at all is in [point a domain at your server and add SSL](https://www.renode.space/blog/your-domain-and-its-certificate).

#### Read the error code before anything else

The big red page is designed to scare visitors off, not to help you, so the useful part is small. In Chrome and Edge it is the grey code under the explanation, or behind "Advanced". In Firefox it is behind "Advanced" as well and has a different spelling. Safari mostly says "This Connection Is Not Private" and hides the reason behind "Show Details" and "view the certificate".

| Chrome / Edge | Firefox | What it means |
|---|---|---|
| `NET::ERR_CERT_DATE_INVALID` | `SEC_ERROR_EXPIRED_CERTIFICATE` | Expired, not yet valid, or wrong clock |
| `NET::ERR_CERT_COMMON_NAME_INVALID` | `SSL_ERROR_BAD_CERT_DOMAIN` | Certificate does not list this name |
| `NET::ERR_CERT_AUTHORITY_INVALID` | `SEC_ERROR_UNKNOWN_ISSUER` | Untrusted issuer or missing intermediate |
| `NET::ERR_CERT_AUTHORITY_INVALID` | `MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT` | Self-signed certificate |
| `ERR_SSL_PROTOCOL_ERROR` | `SSL_ERROR_RX_RECORD_TOO_LONG` | Not TLS on that port, often plain HTTP |
| `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` | `SSL_ERROR_NO_CYPHER_OVERLAP` | No TLS version or cipher in common |
| `NET::ERR_CERT_REVOKED` | `SEC_ERROR_REVOKED_CERTIFICATE` | The authority withdrew the certificate |

Two more things to note while you are on that page. First, whether there is a "Proceed anyway" link: when there is not, the site has HSTS set (or is on a preloaded HSTS domain such as anything under `.dev` or `.app`), and the browser refuses to let anyone click through. That is HSTS doing its job, not a separate fault. Second, who sees it. A warning that only one person gets is almost always their machine - clock, antivirus, a corporate proxy, hotel wifi. A warning everybody gets is the server.

#### Check from outside with openssl s_client

The browser has caches, saved exceptions and its own copy of intermediates it picked up elsewhere. `openssl s_client` has none of that. It connects, does the handshake, prints exactly what the server sent, and tells you whether it verifies. Run it from a machine that is not on the same network as the server, because a local DNS override or a hosts-file entry will happily show you a different server than the world sees.

```bash
$ openssl s_client -connect example.com:443 -servername example.com </dev/null
```

`-servername` matters. It sends SNI, the part of the handshake that tells a server hosting many sites which certificate you want. Without it you get whatever the server considers its default, which is often the wrong certificate and sends you chasing a problem that does not exist. Recent OpenSSL versions send SNI automatically from the `-connect` host, but older ones do not, so pass it every time. The `</dev/null` closes the connection as soon as the handshake is done instead of waiting for you to type an HTTP request.

The output is long. The three parts that matter:

```text
Certificate chain
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
...
Verify return code: 0 (ok)
```

The chain lists what the server sent: `0` is the leaf (your certificate), `s:` is the subject and `i:` is the issuer. Each certificate's issuer should be the next one's subject, ending at something a browser trusts. The intermediate names change over time, so do not worry if yours are not `R11`; worry if there is only a `0` line. The last line is the verdict. `0 (ok)` means the chain checks out against your system's trust store. Anything else is the error, in OpenSSL's words.

To see the dates and names without the noise, pipe the certificate into `openssl x509`:

```bash
$ openssl s_client -connect example.com:443 -servername example.com </dev/null \
    2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
```

`-ext` needs OpenSSL 1.1.1 or later. On an older system use `-text` and look for the `X509v3 Subject Alternative Name` block by eye.

The verify codes you will actually see:

| Code | Message | Meaning |
|---|---|---|
| `10` | certificate has expired | Renewal failed or was never set up |
| `18` | self-signed certificate | Leaf signed by itself |
| `19` | self-signed certificate in certificate chain | Private CA the client does not trust |
| `20` | unable to get local issuer certificate | Chain incomplete, or no trust store |
| `21` | unable to verify the first certificate | Server sent the leaf without its intermediate |
| `62` | hostname mismatch | Name not in the certificate (with `-verify_hostname`) |

Note that `s_client` does not check the name by default - it will say `0 (ok)` for a valid certificate belonging to an entirely different domain. Add `-verify_hostname example.com` to make it check, or read the `subjectAltName` output yourself.

#### NET::ERR_CERT_DATE_INVALID: expired, or a clock that is wrong

A certificate has a "not before" and a "not after" date, and outside that window it is invalid. There are two very different ways to land outside it.

**The certificate really expired.** If `openssl x509 -dates` shows a `notAfter` in the past, renewal failed. With Let's Encrypt certificates lasting 90 days, that means something has been failing quietly for about a month, because clients normally renew with 30 days to spare. The usual causes are the same ones that stop issuance in the first place: the domain no longer points at the server, port 80 was closed by a firewall change, a CDN was switched on in front of the origin, or the renewal timer was never installed because the first certificate was obtained by hand. Check the renewal log - for Certbot that is `/var/log/letsencrypt/letsencrypt.log` - and run `certbot renew --dry-run` to reproduce the failure on demand.

The other expired-certificate trap: renewal succeeded and nothing reloaded. Certbot writes the new files, but nginx and most other servers read certificates once at start-up and keep serving the old one from memory until they are reloaded. If the file on disk has a fresh date and `s_client` shows the old one, that is your problem. A deploy hook fixes it permanently:

```bash
$ certbot renew --deploy-hook "systemctl reload nginx"
```

Shorter certificate lifetimes are coming across the whole industry, not just at Let's Encrypt - the CA/Browser Forum has agreed to step the maximum down over the next few years, ending at 47 days in 2029. Anything still renewed by a person with a calendar reminder will break more often every year. If a certificate on your system is renewed by hand, that is the one to automate first.

**The visitor's clock is wrong.** If only one person sees the error, and their browser says something like "Your clock is behind", the certificate is fine and their computer thinks it is a different year. This happens on machines with a dead motherboard battery, dual-boot systems where Windows and Linux disagree about whether the hardware clock is local time or UTC, and virtual machines restored from an old snapshot. Fixing the system time fixes every site at once. A site owner cannot fix this, and should not try.

**Not yet valid.** Rarer, but it happens: a brand-new certificate whose `notBefore` is a few seconds in the future relative to a server or client clock that drifts. Keep NTP running on servers - `timedatectl` should report `System clock synchronized: yes`.

#### NET::ERR_CERT_COMMON_NAME_INVALID: the certificate is for a different name

The name in the address bar must appear in the certificate's Subject Alternative Name list. Browsers ignore the old Common Name field entirely - Chrome stopped reading it in 2017 - so a certificate that only has the name in `CN` fails even though it looks right at a glance. That mostly bites home-made certificates; every public authority puts names in the SAN list.

The common ways to end up here:

- **`www` and the apex are different names.** A certificate for `example.com` does not cover `www.example.com`, and the reverse is also true. Either get one certificate listing both, or redirect one to the other - and the redirect itself needs a valid certificate on the name it redirects from, because the browser checks the certificate before it ever sees the redirect. [www vs apex domain and redirects](https://www.renode.space/blog/www-vs-apex-domain-and-redirects) covers choosing which one is canonical.
- **Wildcards cover exactly one label.** `*.example.com` covers `shop.example.com` but not `example.com` and not `api.shop.example.com`. Wildcard certificates that "do not work" are almost always being asked to cover one of those two.
- **No SNI match on a shared server.** When the server has no certificate configured for the name requested, it falls back to its default - which belongs to some other site on the same machine. In nginx that means the request landed in a `server` block whose `server_name` does not include your host, so the first `listen 443 ssl` block (or the one marked `default_server`) answered instead. Check `server_name` for typos, and run `nginx -T` to see the configuration nginx actually loaded rather than the file you think you edited. [The nginx reverse proxy guide](https://www.renode.space/blog/nginx-reverse-proxy-guide) has a working layout.
- **The DNS points at the wrong machine.** You moved the site, the old A record is still there or still cached, and visitors are reaching the previous host, which has a certificate for something else entirely. Compare `dig +short example.com @1.1.1.1` with the address you expect. [DNS records explained](https://www.renode.space/blog/dns-records-explained) covers the records involved.
- **Visiting by IP address.** `https://203.0.113.10` will never match a domain certificate. Use the name.

#### Incomplete chain: works in your browser, fails everywhere else

This is the error that wastes the most time, because the person who set up the server cannot reproduce it. The certificate is valid, the name is right, the dates are fine, and the site loads in their browser. Meanwhile `curl` fails with `curl: (60) SSL certificate problem: unable to get local issuer certificate`, a Python script raises `CERTIFICATE_VERIFY_FAILED`, a payment provider's webhook refuses to deliver, and some phones show a warning.

```diagram
title: The browser walks from your certificate to a root it already has
leaf = Your certificate | sent by the server
inter = Intermediate | must also be sent
root = Root certificate | already in trust store
client = Browser or curl
leaf -> inter : signed by
inter -> root : signed by
client --> root : trusts
```

A public authority does not sign your certificate with its root key, which is kept offline. It signs with an intermediate, and the intermediate is signed by the root. Browsers and operating systems ship the roots; they do not necessarily have the intermediate. So the server has to send both: your certificate first, then the intermediate. Send only the first and the client is holding a certificate signed by someone it has never heard of.

Desktop browsers often hide the mistake. Chrome and others will go and fetch a missing intermediate using a URL inside the certificate, or use one cached from another site that sent it properly. Command-line tools, programming-language HTTP libraries and many other clients do not, and fail. `s_client` shows it instantly: only a `0` line in the chain, and `Verify return code: 21 (unable to verify the first certificate)`.

The fix is to serve the full chain file instead of the bare certificate. With Certbot that means `fullchain.pem` rather than `cert.pem`:

nginx:

```nginx
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
```

Apache 2.4.8 and later read the chain from the same file, so `SSLCertificateFile` should point at `fullchain.pem` too; the old `SSLCertificateChainFile` directive is deprecated. With a certificate bought from a commercial authority, you usually get your certificate and a "bundle" or "CA bundle" file separately. Concatenate them, your certificate first:

```bash
$ cat example_com.crt ca-bundle.crt > fullchain.crt
```

Order matters. If the intermediate comes first, nginx will complain that the key does not match the certificate, because it compares the key against the first certificate in the file. Do not include the root itself - clients have it already, and sending it only makes every handshake bigger.

#### Self-signed certificates and NET::ERR_CERT_AUTHORITY_INVALID

A self-signed certificate is signed by its own key. It encrypts traffic exactly as well as any other, but nothing vouches for it, so every browser rejects it with `NET::ERR_CERT_AUTHORITY_INVALID`. There is nothing to fix on the browser side for a public site: get a real certificate. Let's Encrypt costs nothing and can be fully automated, so a self-signed certificate on a public domain in 2026 is a configuration that was meant to be temporary.

Where you do see them legitimately:

- **A fresh install.** Many control panels, routers, NAS boxes, Proxmox, and default web server packages generate a self-signed certificate at install time so HTTPS works at all. The warning is expected until you install a proper one.
- **Local development.** Generating one takes a single command:

```bash
$ openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
    -keyout localhost.key -out localhost.crt \
    -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"
```

The `-addext` line is the part people leave out, and without it Chrome rejects the certificate on name grounds as well as trust grounds. For anything beyond a quick test, `mkcert` is easier: it creates a small local authority, installs it into your system and browser trust stores, and issues certificates that your own machine trusts with no warnings - and no other machine does.

- **Internal services with a private CA.** Companies run their own authority for internal names. Those certificates show `19 (self-signed certificate in certificate chain)` in `s_client` on any machine that has not had the company root installed. The fix is distributing the root, not disabling verification.

Two warnings about the shortcuts. Clicking "Proceed" teaches users to click "Proceed", which is precisely the habit an attacker needs. And `curl -k`, `verify=False` in Python requests, or `NODE_TLS_REJECT_UNAUTHORIZED=0` turn off the check entirely - left in production code, they mean any machine in the path can read and alter the traffic. Point the client at the right CA file instead: `curl --cacert`, `REQUESTS_CA_BUNDLE`, or `NODE_EXTRA_CA_CERTS`.

> [!WARNING] A disabled certificate check never announces itself. The code works, the tests pass, and the protection is gone. Search the codebase for `verify=False`, `-k` and `rejectUnauthorized: false` before shipping.

#### ERR_SSL_PROTOCOL_ERROR: the handshake never finished

Every error above means a certificate arrived and the browser disliked it. `ERR_SSL_PROTOCOL_ERROR` usually means no certificate arrived at all, because what answered on the port was not speaking TLS the way the browser expected.

**Plain HTTP on the HTTPS port.** By far the most common cause. A server block has `listen 443;` without `ssl`, an application is listening on 443 directly with no TLS configured, or you typed `https://` in front of a port that only does HTTP, such as an app's own `:3000` or `:8080`. Firefox's version of the error, `SSL_ERROR_RX_RECORD_TOO_LONG`, says it more honestly: it received an HTTP response and tried to read it as TLS. `s_client` reports `wrong version number` or `packet length too long` in its error lines. Check the `listen` directive:

```nginx
listen 443 ssl;
http2 on;
```

`http2 on;` is the form from nginx 1.25.1 onwards; older versions write `listen 443 ssl http2;`. Either way, `ssl` has to be there.

**No certificate configured for that name.** Some servers, given an SNI name they have no certificate for and no default, abort the handshake rather than sending something wrong. The browser reports a protocol error. Same fix as the name-mismatch case: make the name exist in the configuration.

**Old protocol versions only.** Current browsers have dropped TLS 1.0 and 1.1. A server, appliance or old embedded device that only speaks those produces `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` rather than a protocol error, but the test is the same:

```bash
$ openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null
$ openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null
```

If both fail, the server needs updating, and in nginx `ssl_protocols TLSv1.2 TLSv1.3;` is the line to set.

**Something in the middle.** Antivirus software with "HTTPS scanning", a corporate inspection proxy, and captive portals on hotel and train wifi all intercept TLS. Depending on how well they do it, the result is a protocol error or an authority error, and the certificate's issuer, when you can see it, is the antivirus vendor or the company rather than a public authority. If only one network or one machine has the problem, this is the first suspect. The page itself cannot fix it.

**CDN in front, origin wrong.** Behind Cloudflare, a broken origin shows up as the CDN's own error pages: `525` means the handshake between the CDN and your server failed, `526` means the CDN's "Full (strict)" mode found your origin certificate invalid. Its "Flexible" mode combined with an origin that redirects to HTTPS gives `ERR_TOO_MANY_REDIRECTS` instead. [Cloudflare for websites and game servers](https://www.renode.space/blog/cloudflare-for-websites-and-game-servers) covers which mode belongs with which origin.

#### Valid certificate, still "Not secure": mixed content

When the certificate is fine but the padlock is missing, struck through, or replaced by "Not secure", the page loaded over HTTPS is pulling something in over plain HTTP. Scripts, stylesheets and iframes requested over `http://` are blocked outright, which usually shows up as a page with missing styling or a broken menu rather than as an obvious error. Images and media are upgraded to HTTPS automatically by current Chrome, or blocked when the upgrade fails.

The browser console names every offending URL, which turns this from a hunt into a list. In Chrome it reads like:

```text
Mixed Content: The page at 'https://example.com/' was loaded over HTTPS,
but requested an insecure script 'http://example.com/js/menu.js'.
This request has been blocked; the content must be served over HTTPS.
```

The fix is to stop generating `http://` URLs - usually old absolute URLs saved in a CMS database, a hard-coded asset host in a theme, or an application behind a reverse proxy that does not realise the visitor arrived over HTTPS and builds its links accordingly. That last one needs the app to trust `X-Forwarded-Proto` from the proxy. Both [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained) and [point a domain at your server and add SSL](https://www.renode.space/blog/your-domain-and-its-certificate) cover the cleanup and the proxy settings, and [migrate WordPress to a new host](https://www.renode.space/blog/migrate-wordpress-to-a-new-host) covers the database search-and-replace safely.

> [!TIP] Open the page in a private window with the console already open before reloading. Mixed-content messages appear during load and are easy to miss if the console is opened afterwards.

#### Where the certificate is handled for you

Most of the failures above are about a person managing files: a renewal that broke, a reload nobody ran, a `cert.pem` where `fullchain.pem` belonged. On RE:NODE's [web hosting](https://www.renode.space/web-hosting) and [app hosting](https://www.renode.space/app-hosting) plans, a proxy slot does that part. You point an A record at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window before expiry. Your application receives ordinary HTTP on its own port and the real visitor address in `X-Forwarded-For`, so it never holds a certificate and there is no chain to assemble.

What it does not change: DNS still lives at your domain provider, so a wrong or stale A record still produces a name or issuance problem, and mixed content is still the application's to fix. When something does look wrong, the `s_client` commands above work against any host, ours included.

#### FAQ

##### Is it safe to click "Proceed anyway"?

On your own server, to confirm a self-signed test certificate, yes. On someone else's site, no: the warning means the browser could not confirm who it is talking to, and the most common legitimate causes look exactly like an interception. Never enter a password or card number after clicking through.

##### Why does the site work in Chrome but not in curl or my app?

The server is sending its certificate without the intermediate. Desktop browsers fill the gap themselves; most other clients do not. Serve the full chain file and the difference disappears.

##### Why does only one visitor see a certificate error?

Because the problem is on their side - a wrong system clock, antivirus HTTPS scanning, a workplace proxy or a public wifi login page. Ask them for the exact error code and the issuer shown in the certificate details.

##### How do I check when my certificate expires?

Run `s_client` piped into `openssl x509 -noout -dates` from outside your network, as shown above. `notAfter` is the expiry. For anything important, have a monitoring check alert when fewer than 14 days remain, so a broken renewal is noticed before visitors notice it.

##### Can I clear an HSTS error for a site I am working on?

In Chrome, `chrome://net-internals/#hsts` has a "Delete domain security policies" box that removes a site's HSTS entry from your own browser. It cannot remove a domain on the browser's built-in preload list, and the real fix is still a valid certificate.

##### Does a free certificate cause more errors than a paid one?

No. Browsers trust Let's Encrypt the same way they trust any commercial authority, and the errors in this post happen with paid certificates just as often - more often for missing chains, since the bundle file arrives separately.

Web and app plans on RE:NODE include a proxy slot: point an A record at the address shown and the certificate is issued and renewed automatically, inside a 21-day window. Your app gets plain HTTP on its own port and the visitor's address in X-Forwarded-For.

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

---

### WP-Cron vs real cron: fix missed WordPress schedules

Published 2026-10-15, updated 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/wordpress-wp-cron-vs-real-cron

WP-Cron is not a scheduler. It is a check that runs on page loads: every time someone requests a page, WordPress looks at its list of jobs, and if one is overdue it fires off a background request to `wp-cron.php` to run it. No visitors means no jobs, and lots of visitors means a lot of checking. The fix most sites want is three steps: add `define( 'DISABLE_WP_CRON', true );` to `wp-config.php`, call `wp-cron.php` (or `wp cron event run --due-now`) from a real cron every five minutes, and confirm with `wp cron event list` that the queue is draining. The rest of this post explains why that works, when it is not worth doing, and how to find out why a scheduled post sat there with "Missed schedule" next to it.

#### How WP-Cron actually works

WordPress keeps every scheduled job in one database row: the `cron` option in the `wp_options` table, a serialized array keyed by Unix timestamp. Each entry names a hook (an action name such as `wp_version_check` or `publish_future_post`), its arguments, and, for repeating jobs, a recurrence such as `hourly` or `daily`. Plugins add their own entries with `wp_schedule_event()` or `wp_schedule_single_event()`.

On every request, early in loading, WordPress calls `wp_cron()`. That function reads the `cron` option and asks one question: is the earliest timestamp in the past? If not, nothing happens and the cost is tiny. If something is due, it calls `spawn_cron()`, which does the following:

1. Checks a lock, the `doing_cron` transient. If another spawn started less than `WP_CRON_LOCK_TIMEOUT` seconds ago (60 by default), it gives up.
2. Sets the lock to the current time.
3. Sends an HTTP POST from the server to itself - a loopback request - to `https://your-site/wp-cron.php?doing_wp_cron=<timestamp>`, with a timeout of 0.01 seconds and `blocking` set to false, so the visitor's page does not wait for it.
4. The new request to `wp-cron.php` boots WordPress, walks the due jobs in order, runs each hook, reschedules the repeating ones and removes the single ones.

```diagram
title: What a single page view can set off
visitor = Visitor | any page request
wp = WordPress | checks the cron option
loop = Loopback request | POST to itself
cron = wp-cron.php | runs due hooks
db = wp_options | the cron row
visitor -> wp
wp -> db : anything due?
wp -> loop : only if due
loop -> cron
cron <-> db : run, reschedule
```

The design is clever for what it was built for: shared hosting in the 2000s, where most users could not edit a crontab at all. It needs no configuration and works on any host that lets PHP make an HTTP request to its own domain. Its weaknesses all follow from the trigger being a visitor.

- **Timing is approximate.** A job "scheduled for 03:00" runs at the first page view after 03:00. On a quiet site that might be 07:40.
- **Quiet sites stall.** A brochure site with ten visits a day runs its hourly jobs ten times a day, not twenty-four.
- **Busy sites check constantly.** The check itself is cheap, but every spawned loopback is a full WordPress boot, and long jobs compete with real visitors for PHP workers.
- **Cached pages do not count.** A visit served from a full-page cache never reaches PHP, so it never triggers WP-Cron. The better your caching, the less often cron runs.
- **The loopback can fail silently.** If the server cannot reach its own public address, nothing errors on the page - jobs simply never run.

#### The built-in schedules and what runs on them

Core registers four recurrences: `hourly`, `twicedaily`, `daily` and `weekly`. Plugins add more through the `cron_schedules` filter, which is why you see names like `every_minute` or `five_minutes` in a busy site's list. A fresh install schedules a handful of core jobs:

| Hook | Recurrence | What it does |
| --- | --- | --- |
| `wp_version_check` | `twicedaily` | Checks for a new WordPress release |
| `wp_update_plugins` | `twicedaily` | Checks plugin updates |
| `wp_update_themes` | `twicedaily` | Checks theme updates |
| `wp_scheduled_delete` | `daily` | Empties the trash after 30 days |
| `delete_expired_transients` | `daily` | Clears expired transients |
| `wp_privacy_delete_old_export_files` | `hourly` | Removes old personal-data exports |
| `wp_site_health_scheduled_check` | `weekly` | Runs the Site Health tests |

Exact names and counts shift between WordPress versions, so treat the table as typical rather than definitive and check your own with `wp cron event list`. Automatic background updates also run through cron, as do scheduled posts: when you schedule a post, WordPress adds a single `publish_future_post` event at the post's time with the post ID as its argument.

Then come the plugins. Backup plugins schedule their runs, SEO plugins rebuild sitemaps, security plugins run scans, and WooCommerce brings Action Scheduler, its own queue that uses WP-Cron as one of its triggers and can carry thousands of pending actions on a store. On a site with thirty plugins it is normal to find sixty or more events, and sometimes a handful left behind by plugins that were deleted without cleaning up.

Registering your own job looks like this. The `wp_next_scheduled()` check matters - without it, every page load would add another copy of the event.

mu-plugins/nightly-cleanup.php:

```php
<?php
add_action( 'mysite_nightly_cleanup', 'mysite_do_cleanup' );

function mysite_do_cleanup() {
    // Delete drafts older than 90 days, prune a log table, and so on.
}

if ( ! wp_next_scheduled( 'mysite_nightly_cleanup' ) ) {
    wp_schedule_event( strtotime( 'tomorrow 03:15' ), 'daily', 'mysite_nightly_cleanup' );
}
```

WP-Cron stores times as UTC timestamps. The time shown in the editor for a scheduled post uses the site timezone from Settings, General, and WordPress converts between the two, so the server's own timezone does not matter - but a server clock that is simply wrong does.

#### DISABLE_WP_CRON: turning off the page-load trigger

The constant has a narrow, often misunderstood effect. It stops `wp_cron()` from spawning on page loads. It does not disable scheduled jobs, it does not remove anything from the `cron` option, and `wp-cron.php` still runs every due job when something requests it. You are removing the trigger, not the scheduler, which is why the constant is only half the change - the other half is giving WordPress a new trigger.

wp-config.php:

```php
define( 'DISABLE_WP_CRON', true );

/* That's all, stop editing! Happy publishing. */
```

Put it above the "stop editing" line. Anything placed after `wp-settings.php` is loaded has no effect, and that is the most common reason people report the constant "not working".

> [!WARNING]
> Setting `DISABLE_WP_CRON` without adding a real cron is how sites quietly stop publishing scheduled posts, sending scheduled emails and checking for security updates. Nothing breaks on the front end, so it can go unnoticed for weeks. Add the replacement trigger in the same sitting and check that it fires.

##### When to leave WP-Cron alone

Not every site needs this. A small blog with steady traffic, no full-page cache in front of PHP and no time-sensitive jobs runs fine on the default, and adding a system cron is one more thing to break when you move hosts. The change is worth making when one of these is true:

- Scheduled posts or emails need to go out on time, not "some time after".
- Traffic is low or irregular, so jobs run hours late.
- A full-page cache or a CDN serves most requests, so PHP rarely sees a visitor.
- The site is busy, and you want cron work off the request path and out of the way of real visitors.
- WooCommerce, a membership plugin or anything built on Action Scheduler is installed - their queues fall behind fast on page-load triggering.

#### Replacing it with a real cron

A real trigger is anything that runs on a clock and can either make an HTTP request to `wp-cron.php` or run PHP inside the WordPress directory. Five-field cron syntax is the same everywhere; if `*/5 * * * *` needs decoding, [cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) takes it apart field by field.

##### Option 1: request wp-cron.php over HTTP

This works from anywhere - the same server, a different server, a small VDS you already run, or a hosted cron service - because it is just a web request. It is the only option when the hosting gives you no shell.

crontab -e:

```bash
*/5 * * * * curl -fsS -o /dev/null --max-time 120 "https://example.com/wp-cron.php?doing_wp_cron"
```

Or with `wget`, which some minimal systems have instead:

```bash
*/5 * * * * wget -q -O /dev/null --timeout=120 "https://example.com/wp-cron.php?doing_wp_cron"
```

The `--max-time` matters. Without it, a hung request can stay open indefinitely while cron starts another one five minutes later. The jobs still run inside a normal web request, so they are bound by the web server's and PHP's own time limits - a backup plugin that needs ten minutes may be cut off by `max_execution_time` or a proxy timeout. [PHP ini settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers which limit is which.

##### Option 2: run it with WP-CLI

If you have a shell on the machine where WordPress lives, WP-CLI is the better trigger. It runs under the PHP command-line binary, which normally has no execution time limit, nothing goes through the web server, and there is no loopback to fail.

crontab -e (as the site's user):

```bash
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet
```

Run it as the same user that owns the WordPress files, not as root. WP-CLI refuses to run as root without `--allow-root` for good reason: files a job creates (cache files, uploads, backup archives) would be owned by root, and the web server could no longer change them. If one run can take longer than the interval, wrap the command in `flock -n /tmp/wp-cron-example.lock` so a second copy does not start on top of the first.

Calling `php wp-cron.php` directly from the WordPress directory also works and needs no WP-CLI, though WP-CLI gives you far better tools to see what happened.

##### How often to run it

| Site | Interval | Why |
| --- | --- | --- |
| Blog or brochure site | `*/15 * * * *` | Nothing is urgent; scheduled posts at most 15 minutes late |
| Blog with timed posts or newsletters | `*/5 * * * *` | The usual default |
| WooCommerce or memberships | `* * * * *` | Action Scheduler queues and payment retries |
| Multisite network | `*/5 * * * *` per site | Each site has its own `cron` option |

Running every minute is cheap when nothing is due - the request checks a timestamp and exits. The cost appears only when a lot of work is waiting, and then you want it running promptly anyway. On multisite, remember that each site keeps its own queue: loop over `wp site list --field=url` and run `wp cron event run --due-now --url=<site>` for each, or request each site's `wp-cron.php`.

#### WP-CLI: seeing and running the queue

WP-CLI is the quickest way to answer "is cron working". The first command shows everything waiting:

```bash
$ wp cron event list --fields=hook,next_run_relative,recurrence
+------------------------------------+------------------------+---------------+
| hook                               | next_run_relative      | recurrence    |
+------------------------------------+------------------------+---------------+
| publish_future_post                | 3 hours 20 minutes ago | Non-repeating |
| wp_privacy_delete_old_export_files | 2 hours 1 minute ago   | 1 hour        |
| wp_version_check                   | 5 hours 12 minutes     | 12 hours      |
| wp_scheduled_delete                | 17 hours 4 minutes     | 1 day         |
+------------------------------------+------------------------+---------------+
```

Anything showing "ago" is overdue. One or two events a minute or so late is normal; a column of events hours in the past means nothing is triggering cron at all. The useful commands:

| Command | What it does |
| --- | --- |
| `wp cron event list` | Every scheduled event with its next run |
| `wp cron event run --due-now` | Runs every event whose time has passed |
| `wp cron event run <hook>` | Runs one hook now, regardless of schedule |
| `wp cron event schedule <hook> now hourly` | Adds an event |
| `wp cron event delete <hook>` | Removes the scheduled events for a hook |
| `wp cron schedule list` | Lists the available recurrences |
| `wp cron test` | Checks that WP-Cron can spawn a loopback |

Running a single hook with `wp cron event run <hook>` is the best way to debug a plugin's job: you see its errors and warnings in your terminal instead of losing them in a background request nobody watches. Add `--debug` for WP-CLI's own trace of what loaded.

`wp cron test` checks the page-load route - it sends the same loopback that `spawn_cron()` would, and it also tells you if `DISABLE_WP_CRON` is set. With the constant on, a warning there is expected; what matters then is whether your system cron is draining the list.

If you have no shell, the WP Crontrol plugin shows the same list inside the admin, lets you run an event immediately, and flags events whose hook has no code behind it any more. It is worth installing just for the diagnosis and removing afterwards.

#### Missed scheduled posts

"Missed schedule" in the posts list means the `publish_future_post` event for that post was due and had not run. WordPress shows the label once the scheduled time has passed with the post still in the `future` status. Almost every case is one of these:

1. **No trigger.** Nobody visited between the scheduled time and now, everything was served from cache, or `DISABLE_WP_CRON` is set with no replacement. The event is still in the queue; the next cron run will publish the post.
2. **The loopback fails.** The server cannot reach its own domain. Common causes: the site's DNS points at a CDN or a proxy the server cannot reach from inside, hairpin NAT that drops connections from a server to its own public address, HTTP basic auth on a staging site, a security plugin or firewall that blocks requests to `wp-cron.php`, or a self-signed certificate on a local copy.
3. **The job died.** An earlier job in the same run hit a fatal error or ran out of memory, and the rest of the queue never ran. One broken plugin hook can hold up everything behind it.
4. **The event vanished.** The post is scheduled but `wp cron event list` shows no `publish_future_post` for it. This is rarer - races when two requests write the `cron` option at once, or a persistent object cache serving a stale copy of the options, are the explanations people usually find.

For the first three, fixing the trigger fixes the post: run `wp cron event run --due-now` and it publishes. For the fourth, open the post and publish it by hand, or set a new time and schedule it again, which creates a fresh event. Plugins that "publish missed scheduled posts" exist, and all they do is check for overdue future posts on page loads and publish them - a workaround for the trigger problem, not a fix, and redundant once a real cron is in place.

> [!TIP]
> To prove scheduling works before you trust it with a launch, schedule a throwaway private post five minutes ahead, then watch it with `wp cron event list` and the posts list. If it publishes within one cron interval of its time, the chain is sound.

#### ALTERNATE_WP_CRON and other workarounds

wp-config.php:

```php
define( 'ALTERNATE_WP_CRON', true );
```

`ALTERNATE_WP_CRON` exists for servers where the loopback cannot work and there is no way to add a real cron. Instead of WordPress calling itself, it redirects the visitor's browser to the same URL with `?doing_wp_cron=` appended, and runs the due jobs during that request after sending the page. It does get jobs running, but at a price: a visible query string in the address bar, an extra redirect on whichever page view happens to trigger it, a URL variant that can end up shared or cached, and cron work still tied to visitor traffic. Treat it as a stopgap. If you can schedule an HTTP request to `wp-cron.php` from anywhere at all, do that instead and leave this constant off.

Two related constants are worth knowing. `WP_CRON_LOCK_TIMEOUT` sets the lock window in seconds; raising it does nothing useful for most sites, but some people lower it when page-load cron is left on and jobs must start more often. `DISABLE_WP_CRON` and `ALTERNATE_WP_CRON` should not both be set - with the first one on, the second never gets a chance to act.

#### wp-cron.php, caching and security

Even with `DISABLE_WP_CRON` set, `wp-cron.php` answers anyone who requests it and runs whatever is due. That is usually harmless - if nothing is due, the request exits quickly - but an attacker or a broken bot hitting it in a loop makes PHP boot WordPress on every request. [WordPress security hardening](https://www.renode.space/blog/wordpress-security-hardening) treats it alongside `xmlrpc.php` as an endpoint worth limiting. If your cron runs on the same machine through WP-CLI, nothing needs to reach `wp-cron.php` over HTTP at all, and you can deny it at the web server:

```nginx
location = /wp-cron.php {
    allow 127.0.0.1;
    deny all;
}
```

If an external machine calls it over HTTP, allow that machine's address instead of `127.0.0.1`, or rate-limit the location rather than blocking it outright. Do not block it before the replacement trigger is working, or you are back to missed schedules.

Caching works in your favour once cron is on a clock. A full-page cache in front of WordPress, as described in [WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching), means fewer requests reach PHP, which with page-load cron meant fewer chances to run jobs. With a real trigger the two stop interfering: the cache serves visitors and the cron serves the queue. Exclude `wp-cron.php` from any cache rule - most setups do by default because it carries a query string, but a CDN rule that caches "everything" can swallow it.

The memory side matters too. Cron requests run under `WP_MAX_MEMORY_LIMIT` (256M by default), not the lower front-end limit, so a heavy job can briefly use more than any page view does. If a site falls over every night around the same time, look at what cron runs at that hour before blaming traffic - [how much RAM WordPress needs](https://www.renode.space/blog/how-much-ram-does-wordpress-need) shows how to measure it.

#### Running WordPress cron on RE:NODE

The WordPress line on [RE:NODE web hosting](https://www.renode.space/web-hosting) installs WordPress on first start with `wp-config.php` already written for its own MySQL database, so `DISABLE_WP_CRON` goes into a file you can open straight away in the in-browser file manager or over SFTP. Out of the box, nothing is changed: WP-Cron runs on page loads exactly as it does anywhere else.

The panel has a Schedules tab that takes a standard cron expression and runs ordered tasks with delays between them - a console command, a backup or a power action ([the Schedules guide](https://www.renode.space/guides#schedules) walks through it). On a WordPress site that is the natural place for the jobs around WordPress rather than inside it: a nightly backup at an hour with no editors online, and a backup a few minutes before a scheduled bulk change. What it is not described as is a general way to request a URL, so for the `wp-cron.php` trigger itself use an HTTP cron from outside - another server you run, or any hosted cron service - with the `curl` line above pointed at your domain. Your site already serves HTTPS on its own domain through the plan's proxy slot, so the request is a normal one.

Two platform details matter for cron jobs specifically. CPU is a hard limit at the share the plan includes, so a heavy job on a small plan runs slowly rather than failing; and if a job pushes the server past its memory limit, the container is stopped and restarted clean instead of swapping, which ends that cron run part-way. If a nightly job is big enough to hit either, spread it out or move up a plan - [background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) covers the general pattern.

#### Troubleshooting checklist

**Events are hours overdue in `wp cron event list`.** Nothing is triggering cron. If `DISABLE_WP_CRON` is set, your system cron is not running or not reaching the site - run the exact `curl` line by hand and look at the status code. If it is not set, the site has too little uncached traffic or the loopback fails; `wp cron test` tells you which.

**The curl line returns 403 or a login page.** A security plugin, a web application firewall or basic auth is blocking the request. Allow the caller's address, or switch to the WP-CLI trigger on the same machine.

**The curl line returns 200 but nothing runs.** Check the URL is the site's real address and not a redirect to `www` or HTTPS - add `-L` to follow it, or better, use the final URL directly. On multisite, check you are calling the right site.

**One job never completes and everything behind it is late.** Run that hook alone with `wp cron event run <hook>` and read the error. Fatal errors in background requests go to the PHP error log, not the screen, so enable `WP_DEBUG_LOG` while you look and turn it off afterwards.

**The same job runs twice.** Two triggers are active - page-load cron and a system cron, or two crontab lines left from an old setup. Set `DISABLE_WP_CRON` and keep exactly one schedule.

**Hundreds of events for a plugin you deleted.** The plugin left its hooks behind. Remove them with `wp cron event delete <hook>` or the WP Crontrol plugin. They are harmless but clutter the list and make real problems harder to spot.

#### FAQ

##### Does DISABLE_WP_CRON stop scheduled posts from publishing?

Only if nothing else triggers cron. The constant removes the page-load trigger; the scheduled events stay in the queue and run as soon as something calls `wp-cron.php` or WP-CLI runs them. Pair it with a real cron and scheduled posts become more punctual, not less.

##### How often should a real cron call wp-cron.php?

Every five minutes suits most sites. Shops and anything using Action Scheduler do better at every minute, and a site with no time-sensitive work can use fifteen. A run with nothing due costs one short PHP request.

##### Can I run WP-Cron without shell access?

Yes. The trigger only has to make an HTTPS request to your site, so it can live anywhere: a hosted cron service, a VPS you already have, or a friend's home server. Use the `curl` line with a timeout and point it at the site's final URL.

##### Is ALTERNATE_WP_CRON safe to use?

It works and it is not dangerous, but it exposes a query string to visitors, adds a redirect, and still depends on traffic. Use it only when loopbacks fail and there is no way to schedule an outside request.

##### Why does Site Health say a scheduled event is late?

Site Health compares each event's time with now and flags those that are overdue. One late event usually means a slow trigger; a whole list of them means cron is not running. Check with `wp cron event list` and fix the trigger rather than the warning.

##### Does a page cache break WP-Cron?

It weakens it. Cached responses never reach PHP, so they never trigger cron. On a well-cached site with page-load cron, jobs can wait a long time for an uncached request. Moving to a real cron solves it completely.

The RE:NODE WordPress line installs WordPress with `wp-config.php` already written for its own database, gives you a file manager and SFTP to set `DISABLE_WP_CRON`, and a Schedules tab for nightly backups that are stored off the machine and restored with one button.

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

---

### ASP.NET Core behind a reverse proxy: forwarded headers

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/aspnet-core-behind-a-reverse-proxy

Behind a reverse proxy, ASP.NET Core sees every request as plain HTTP from one address - the proxy's - until you tell it otherwise. The fix is one middleware, `UseForwardedHeaders`, configured to read `X-Forwarded-For` and `X-Forwarded-Proto`, registered before everything else, and told exactly which proxy it may believe. Get that right and the client IP, the scheme, HTTPS redirection, HSTS, secure cookies and OAuth callbacks all fix themselves. Get it wrong in the permissive direction and anyone can choose their own IP address by sending a header.

This post goes through the middleware option by option, then the things that sit next to it: redirect loops, HSTS, WebSockets and SignalR through a proxy, and the timeouts and limits on both sides.

#### What goes wrong behind a proxy

When a proxy terminates TLS and forwards the request to Kestrel over plain HTTP, the connection Kestrel sees is from the proxy, and the original details survive only as headers the proxy added.

```diagram
title: What Kestrel sees with and without forwarded headers
client = Browser | 198.51.100.7, HTTPS
proxy = Reverse proxy | adds X-Forwarded-*
kestrel = Kestrel | plain HTTP
mw = UseForwardedHeaders | rewrites the context
app = Your endpoints | real IP and scheme
client -> proxy : TLS on 443
proxy -> kestrel : HTTP, allocated port
kestrel -> mw
mw -> app
```

Without the middleware, these are all quietly wrong:

- `HttpContext.Connection.RemoteIpAddress` is the proxy's address. Logs show one visitor; a rate limiter partitioned by IP puts every user in one bucket.
- `HttpContext.Request.Scheme` is `http` and `Request.IsHttps` is false.
- `UseHttpsRedirection` redirects requests that already arrived over HTTPS, and the browser loops until it reports too many redirects.
- `UseHsts` never sends its header, because it only adds it to HTTPS responses.
- Cookies with `CookieSecurePolicy.SameAsRequest` are issued without the `Secure` flag.
- Generated absolute URLs, including the `redirect_uri` sent to Google, Microsoft or GitHub during an external login, start with `http://`, and the provider rejects them as a mismatch.

Every one of those is a symptom of the same missing configuration. [What a reverse proxy actually does](https://www.renode.space/blog/what-a-reverse-proxy-does) covers the proxy side in general; this post is about what ASP.NET Core has to do about it.

#### The forwarded headers middleware

The middleware is part of ASP.NET Core; no package is needed.

Program.cs:

```csharp
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); // your proxy, see below
});

var app = builder.Build();

app.UseForwardedHeaders();      // first, before anything that reads IP or scheme
app.UseHsts();
app.UseHttpsRedirection();
// authentication, rate limiting, endpoints...
```

What it does for each request: if the connection comes from a trusted proxy, it reads the forwarded headers, replaces `RemoteIpAddress` and `Scheme` with the values in them, and moves the originals to `X-Original-For` and `X-Original-Proto` so you can still see them. If the connection does not come from a trusted proxy, it does nothing at all.

Order matters. Any middleware registered before `UseForwardedHeaders` sees the proxy's view of the request. Microsoft's guidance is to run it before everything else except diagnostics and error-handling middleware. Request logging in particular has to come after it, or your logs keep recording the proxy.

The options that matter:

| Option | Default | What it controls |
| --- | --- | --- |
| `ForwardedHeaders` | `None` | Which headers to process. Nothing happens until you set it |
| `ForwardLimit` | `1` | How many entries to take from the right of `X-Forwarded-For` |
| `KnownProxies` | loopback | Individual proxy addresses to trust |
| `KnownNetworks` | `127.0.0.0/8` | Address ranges to trust |
| `AllowedHosts` | empty | Hosts accepted from `X-Forwarded-Host` |
| `RequireHeaderSymmetry` | `false` | Require the same number of values in each header |

`ForwardedHeaders.XForwardedHost` is deliberately missing from the example. Turning it on lets the proxy - or anyone who reaches the app directly - change `Request.Host`, which feeds into generated links and password-reset emails. Only enable it with `AllowedHosts` set to your real host names, and only if the proxy rewrites the `Host` header rather than passing it through. Most proxies pass the original `Host` through unchanged, in which case you do not need it.

#### KnownProxies: deciding who to believe

`X-Forwarded-For` is a header, and a client can send any header it likes. The proxy appends the address it actually saw to whatever the client sent, so for a request from `198.51.100.7` that included a forged value, the app receives:

```text
X-Forwarded-For: 6.6.6.6, 198.51.100.7
```

The rightmost entry was written by your proxy and is true. Everything to its left was supplied by the client and is not. With `ForwardLimit = 1` the middleware takes exactly one entry from the right - the true one - which is why the default limit is correct for a single proxy. If there are two proxies in a chain (a CDN in front of your proxy, say), the limit is 2 and both must be trusted.

The other half of the safety is `KnownProxies`. The middleware only processes headers on connections from an address it trusts, and by default that is loopback only. A proxy at any other address is ignored, so a freshly configured app often seems to do nothing: the headers arrive, the middleware sees an unknown source, and skips them silently.

To find out what address the proxy connects from, log it before the middleware runs:

```csharp
app.Use(async (context, next) =>
{
    app.Logger.LogInformation("peer {Peer} xff {Xff}",
        context.Connection.RemoteIpAddress,
        context.Request.Headers["X-Forwarded-For"].ToString());
    await next();
});
app.UseForwardedHeaders();
```

Make one request through the domain, read the address from the log, add it to `KnownProxies` (or the range to `KnownNetworks`), and remove the logging. Check after a restart that the address is the same; if the proxy's source address can change, trust its network rather than one address.

In .NET 10, `KnownNetworks` is marked obsolete in favour of `KnownIPNetworks`, which takes the newer `System.Net.IPNetwork` type. The behaviour is the same; the old property still works and only produces a warning.

> [!WARNING]
> If your app's port is reachable directly from the internet as well as through the proxy - which it is when the port allocation is on a public address - then trusting every source means anyone can connect straight to the port with a forged `X-Forwarded-For` and become whichever IP they like. Trust the proxy's address, not the world.

#### The environment variable shortcut, and its cost

ASP.NET Core can turn the middleware on without code:

```env
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
```

This adds the middleware with `XForwardedFor` and `XForwardedProto` enabled - and it also clears `KnownProxies` and `KnownNetworks`, so it accepts the headers from any source. It was designed for platforms where the app is only reachable through the proxy, so there is nobody else to send them.

On a server where the app port is also reachable directly, that is the forged-IP problem from the warning above. It is fine for a quick test, and acceptable when the client IP is used for nothing more than logs. For anything that makes a decision on the client IP - rate limits, IP allow-lists, fraud scoring, login throttling - configure `KnownProxies` in code instead. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) explains why a forgeable key is worse than no rate limit at all.

Here is what depends on it in a typical app. The built-in rate limiter partitions by whatever key you give it, and the usual key is the client address:

```csharp
builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            }));
});
```

With the middleware missing, that key is the proxy for every request, and the hundredth request in a minute from anyone blocks everyone. With the middleware trusting all sources, the key is whatever the attacker writes, and they get a fresh hundred requests by changing one header. Only the correctly scoped version gives you a limit per real client. `UseRateLimiter` must also come after `UseForwardedHeaders` in the pipeline, for the same reason logging must.

#### HTTPS redirection without a redirect loop

`UseHttpsRedirection` answers HTTP requests with a 307 to the HTTPS URL. Behind a TLS-terminating proxy it has two failure modes.

**The loop.** Without `X-Forwarded-Proto` processed, every request looks like HTTP, so every request is redirected, including the ones that arrived over HTTPS. The forwarded headers configuration above fixes this.

**The silent no-op.** The middleware has to know which port to redirect to. It looks at `HttpsRedirectionOptions.HttpsPort`, then the `https_port` setting (`ASPNETCORE_HTTPS_PORT` in the environment), then the HTTPS endpoints Kestrel is listening on. Behind a proxy, Kestrel has no HTTPS endpoint, so the middleware logs "Failed to determine the https port for redirect" and does nothing. If you want the app to do the redirect, set the port the browser should use:

```env
ASPNETCORE_HTTPS_PORT=443
```

Before you do, check whether the proxy already redirects plain HTTP to HTTPS for your domain. If it does, the app's redirect never fires and you can leave the middleware out. Two layers redirecting is harmless; two layers that disagree about the scheme is how the loop starts. On RE:NODE, the app plan's proxy slot holds the certificate for your domain - point an A record at the address shown and it is issued and renewed automatically inside a 21-day window - and the client address arrives in `X-Forwarded-For`. [Your domain and its certificate](https://www.renode.space/blog/your-domain-and-its-certificate) covers the DNS side.

#### HSTS

HSTS tells the browser to use HTTPS for your host for a period, without asking. In ASP.NET Core:

```csharp
builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(180);
    options.IncludeSubDomains = false;
    options.Preload = false;
});

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}
```

The default `MaxAge` is 30 days. The middleware only adds the header to HTTPS responses, and it never adds it for `localhost`, `127.0.0.1` or `[::1]`, which is why testing locally shows nothing.

Be careful with two settings. `IncludeSubDomains` applies the policy to every subdomain, including the one running an old admin tool on plain HTTP that you forgot about. `Preload` asks to be included in the list browsers ship with, which is very hard to undo. Start with a short `MaxAge`, confirm that every part of the site works over HTTPS, then lengthen it. If the proxy already sends `Strict-Transport-Security`, do not send it twice with different values.

#### WebSockets and SignalR through the proxy

A WebSocket starts as an HTTP request with `Upgrade: websocket` and `Connection: Upgrade` headers, and the proxy has to pass both through and then hold the connection open. Most current proxies do this for HTTP/1.1. On the app side:

```csharp
app.UseWebSockets(new WebSocketOptions
{
    KeepAliveInterval = TimeSpan.FromSeconds(30)
});
```

The default `KeepAliveInterval` is two minutes. A proxy that closes idle connections after 60 seconds will cut a quiet WebSocket long before the app sends its first ping, and the client sees an unexplained disconnect every minute. Keep the interval shorter than the shortest idle timeout between the browser and Kestrel.

SignalR handles this for you to a degree: the server sends a keep-alive every 15 seconds by default (`KeepAliveInterval`), and considers a client gone after 30 seconds without hearing from it (`ClientTimeoutInterval`). If WebSockets fail through the proxy, SignalR falls back to Server-Sent Events or long polling, which work but cost more requests and more latency. Check the browser's network tab: a connection that negotiates and then switches to long polling is usually a proxy that did not pass the upgrade. [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) has the proxy-side detail, and [SignalR real-time apps](https://www.renode.space/blog/signalr-real-time-apps-hosting) covers the hub configuration.

#### Timeouts, body sizes and keep-alive

The proxy and Kestrel each have limits, and a request has to fit inside both.

| Kestrel setting | Default | Notes |
| --- | --- | --- |
| `Limits.KeepAliveTimeout` | 130 seconds | Idle time before Kestrel closes a connection |
| `Limits.RequestHeadersTimeout` | 30 seconds | Time allowed to receive headers |
| `Limits.MaxRequestBodySize` | 30,000,000 bytes | About 28.6 MB; override per endpoint |
| `Limits.MaxConcurrentConnections` | unlimited | Rarely worth setting behind a proxy |

Kestrel's two-minute keep-alive is longer than the idle timeout of most proxies, which is the safe way round: the proxy closes idle connections first, so it never sends a request down a connection Kestrel is about to drop. That race is the classic cause of occasional 502s with nothing in the application log, and it is less common with Kestrel than with Node precisely because of this default. If you lowered `KeepAliveTimeout`, put it back.

For uploads, the smaller of the two body limits wins. Raise Kestrel's limit for the one endpoint that needs it with `[RequestSizeLimit]` on a controller action, or through `IHttpMaxRequestBodySizeFeature` at the start of the request, rather than globally; a large global limit is an easy way to let one client fill your memory. A request that is slower than the proxy's read timeout - a long report, a big export - should become a background job that the client polls, not a two-minute HTTP request. [.NET worker services and background jobs](https://www.renode.space/blog/dotnet-worker-services-and-background-jobs) shows the pattern.

#### Checking that it works

Add a temporary diagnostic endpoint, call it once through the domain, and remove it:

```csharp
app.MapGet("/debug/request", (HttpContext ctx) => new
{
    ip = ctx.Connection.RemoteIpAddress?.ToString(),
    scheme = ctx.Request.Scheme,
    host = ctx.Request.Host.Value,
    originalFor = ctx.Request.Headers["X-Original-For"].ToString()
});
```

Through the domain, `ip` should be your own public address, `scheme` should be `https`, and `originalFor` should show the proxy. Then call the app directly on its IP and port with a forged header:

```bash
$ curl -H "X-Forwarded-For: 1.2.3.4" http://203.0.113.10:25571/debug/request
```

The response must not say `1.2.3.4`. If it does, you are trusting sources you should not. On RE:NODE the app's port is allocated on the server's address, so this direct test is a real path an attacker could use, not a hypothetical one.

The last check is a response header: `Strict-Transport-Security` should appear on HTTPS responses through the domain if you enabled HSTS. If none of this works and the log shows nothing, go back to the logging middleware above and look at the peer address; nine times out of ten it is not in `KnownProxies`.

#### FAQ

##### Why does UseForwardedHeaders seem to do nothing?

Either `ForwardedHeaders` is still `None`, or the proxy's address is not in `KnownProxies` or `KnownNetworks`. The middleware ignores headers from untrusted sources without logging anything at the default level, so log the peer address and compare.

##### Should I set ForwardLimit to null to read the whole header?

No. Taking more entries than you have proxies means trusting values the client wrote. Set the limit to the number of proxies you run, which for a single proxy slot is the default of 1.

##### Do I still need UseHttpsRedirection if the proxy redirects?

Not if the proxy already redirects every HTTP request for your domain. It is harmless to keep, provided `X-Forwarded-Proto` is processed so the app agrees with the proxy about which requests are already HTTPS.

##### Why do external logins fail with a redirect URI mismatch?

The app built the callback URL from `Request.Scheme`, which was `http` because the forwarded proto header was not processed. Fix the forwarded headers and the generated URL becomes `https://`, matching what you registered with the provider.

##### Does the proxy change how I configure Kestrel's ports?

Kestrel listens on plain HTTP on the allocated port, on all interfaces, with no certificate. [Deploy an ASP.NET Core app from GitHub](https://www.renode.space/blog/deploy-aspnet-core-app-from-github) has the `ASPNETCORE_URLS` setup.

Every C# / .NET plan on RE:NODE includes a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, with the real client address passed in `X-Forwarded-For`. Plans start at 1 GB with half a vCPU from $4 a month and deploy straight from a GitHub repository.

[See C# / .NET plans](https://www.renode.space/app-hosting#csharp-dotnet)

---

### WordPress vs Drupal vs Joomla: which CMS for which site

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/cms-hosting-wordpress-drupal-joomla-compared

Pick WordPress unless you have a specific reason not to. It runs over 40% of all websites according to W3Techs' survey, every theme and plugin you might want exists for it, and anyone you hire later will know it. Pick Drupal when the site is really a structured-content application - many content types with fields, fine-grained permissions, editorial workflow, serious multilingual - and you have a developer who will maintain it with Composer. Pick Joomla when you want more built in than WordPress offers (multilingual, access levels, a structured menu system) without Drupal's developer overhead, and you accept a much smaller ecosystem. All three are PHP applications on a MySQL-compatible database, and all three cost you some time every month in updates, whichever you choose.

#### The short comparison

| | WordPress | Drupal | Joomla |
| --- | --- | --- | --- |
| Best fit | Blogs, business sites, shops, most things | Structured content, large or regulated sites | Community and membership sites, multilingual sites |
| Learning curve | Low | High | Medium |
| Extensions | Tens of thousands of free plugins | Thousands of modules, more often for developers | Thousands of extensions, many commercial |
| Multilingual | Plugin needed | In core | In core |
| Roles and permissions | Five fixed roles, plugins to extend | Fully granular in core | Groups and access levels in core |
| Installation style | Upload and run the installer | Composer project | Upload and run the installer |
| Who maintains it | Anyone careful | A developer | A confident site owner |

The table hides the most important difference: how much of the site is the CMS and how much is extensions. A WordPress site is mostly plugins - a contact form, SEO, caching, security, backups, a page builder - and its quality is the quality of those plugins. A Drupal site is mostly core plus configuration, with fewer and larger modules. Joomla sits between. That shapes everything else: security exposure, upgrade pain and who you need to keep it running.

#### Requirements: PHP, database and memory

All three need PHP with the usual extensions (`mysqli` or `pdo_mysql`, `gd` or `imagick`, `mbstring`, `xml`, `zip`, `curl`, `intl` for Drupal and recommended for the others), a web server with URL rewriting, and a MySQL-compatible database. The exact version floors move with each release, so treat these as current at the time of writing and check the project's requirements page before you install.

| | WordPress | Drupal 11 | Joomla 5 |
| --- | --- | --- | --- |
| PHP | 8.3 or newer recommended | 8.3 required | 8.1 minimum, 8.3 recommended |
| MySQL | 8.0 or newer recommended | 8.0 or newer | 8.0.13 or newer |
| Other databases | MariaDB | MariaDB, PostgreSQL, SQLite | MariaDB, PostgreSQL |
| Tooling | Browser installer, optional WP-CLI | Composer and Drush | Browser installer, optional CLI |

Memory is where the real differences show. PHP runs each request in a worker with its own `memory_limit`, and the CMS plus its extensions decide how much a request needs:

- **WordPress** runs a simple site in 64-128 MB per request; WooCommerce and page builders push that to 256 MB or more. A whole small site including the database fits in 1 GB. [How much RAM does WordPress need](https://www.renode.space/blog/how-much-ram-does-wordpress-need) works the numbers by traffic.
- **Drupal** is heavier per request - 128 MB is a realistic floor and 256 MB normal for a site with many modules - but its built-in caching means most anonymous requests never build a page at all.
- **Joomla** sits close to WordPress for a comparable site.

The CMS line on RE:NODE starts at 1 GB for exactly this reason: a single site with a database fits, and the bigger plans are for plugin-heavy installs and several sites side by side. [PHP ini settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers `memory_limit`, `upload_max_filesize` and the rest of what you will want to change.

#### WordPress: the default for good reasons

WordPress is a blogging engine that grew into a general CMS, and the history shows: content is posts and pages, with custom post types and fields (via plugins like Advanced Custom Fields) bolted on. For most sites that is fine, and its advantages are hard to beat:

- **The ecosystem.** Whatever you need - booking, a shop, memberships, a forum, multilingual - there are several plugins, a few of them excellent.
- **The editor.** The block editor is good for people who write, and non-technical staff learn WordPress faster than anything else.
- **The talent pool.** Every web agency and many freelancers know it.
- **Automatic updates.** Minor core releases, which carry security fixes, install automatically by default. Plugin and theme auto-updates can be switched on per item.

The costs are the flip side. Most WordPress compromises come through plugins, not core: an abandoned plugin with a known hole is the usual way in. A site with forty plugins has forty vendors whose code runs with full access to your database. Performance problems are usually one heavy plugin. The fix is discipline rather than a different CMS - fewer plugins, chosen for maintenance history, and updates applied weekly - and [WordPress security hardening](https://www.renode.space/blog/wordpress-security-hardening) covers the rest.

Installation is a zip file and a browser installer: create a database and user, upload the files, fill in `wp-config.php` or let the installer write it. [Installing WordPress manually](https://www.renode.space/blog/install-wordpress-manually) walks through it on a host without a one-click installer.

#### Drupal: structured content and serious permissions

Drupal is a content framework. You define content types with typed fields, taxonomies, relationships between entities, views that list and filter them, and a permission system that can grant exactly one role the right to edit exactly one type of content. Core includes things WordPress needs plugins for:

- **Multilingual** - four core modules translate content, configuration and the interface.
- **Content moderation and workflows** - draft, review, published, with permissions per transition.
- **Views** - the query builder for listings, feeds and REST output, in core since Drupal 8.
- **Layout Builder and caching** - per-page layouts, plus Internal Page Cache and Dynamic Page Cache, which serve most requests without rebuilding the page.
- **JSON:API** in core, which makes Drupal a reasonable headless back end.

Modern Drupal is built and updated with Composer. A new site starts with `composer create-project drupal/recommended-project`, modules are added with `composer require drupal/<module>`, and updates are `composer update` followed by `drush updatedb`. That is the right way to manage a PHP application and the wrong way for a site owner who has never used a terminal. If nobody on the project is comfortable with Composer, Drupal will become the site nobody dares to update.

Major upgrades are incremental since Drupal 8: each major version removes code that was deprecated in the previous one, so a site that keeps up with deprecations moves from 10 to 11 without a rebuild. The painful migration was Drupal 7 to 8, and Drupal 7 reached end of life in January 2025 - any Drupal 7 site still running is unsupported. [The Drupal hosting guide](https://www.renode.space/blog/drupal-hosting-guide) covers `settings.php`, cron and caching in detail.

#### Joomla: more in the box, smaller crowd

Joomla's core is broader than WordPress's out of the box: multilingual sites with language associations, a menu system that drives page layout, user groups and viewing access levels, custom fields, workflows, and a built-in update component that updates core and installed extensions from the administrator. For a club, a school, a membership site or a multilingual brochure site, Joomla can do without extensions what WordPress needs three or four plugins for.

The trade-off is the ecosystem. There are far fewer extensions and templates, a larger share of them commercial, and fewer people who know the system well. The jump from Joomla 3 to 4 broke many templates and extensions and pushed a lot of sites to rebuild; the 4 to 5 upgrade was gentler, with a backward-compatibility plugin to keep older extensions working while their developers caught up. Check that the extensions you depend on support the current major version before you upgrade.

Joomla installs like WordPress: upload the package, create a database, run the web installer, and delete the `installation` folder when it tells you to. [The Joomla hosting guide](https://www.renode.space/blog/joomla-hosting-guide) has the configuration and update details.

#### Upkeep: what each one costs you every month

All three are software that is attacked automatically, all day, by scanners looking for known holes. The CMS you choose is less important than whether you keep it updated.

| Task | WordPress | Drupal | Joomla |
| --- | --- | --- | --- |
| Security releases | Minor core updates install automatically | Security advisories, applied via Composer | Core and extension updates from the admin |
| Typical monthly effort | 15-30 minutes | 30-60 minutes, more with custom code | 15-30 minutes |
| Major upgrades | Rarely breaking for core; plugins vary | Incremental if deprecations are handled | Check extensions first |
| Most common failure | Abandoned plugin | Site falls behind on Composer updates | Extension not updated for new major |

Whatever you run, the routine is the same: a backup before every update, updates applied weekly rather than whenever someone remembers, a short check that the site still works afterwards, and extensions removed - not just disabled - when you stop using them. The database is half the site, so the backup has to include it; see [database backups and restores](https://www.renode.space/blog/database-backups-and-restores).

#### What they cost beyond hosting

All three are free and GPL-licensed. The money goes elsewhere, and it is worth estimating before you choose, because the hosting bill is usually the smallest line.

- **WordPress** is cheap to start and gets more expensive as it grows. The best plugins for forms, SEO, backups, multilingual and shops have free versions and paid tiers sold as yearly licences, and a paid tier is what you need for updates and support. A business site commonly runs three to six of them. Let a licence lapse and that plugin stops receiving updates, which brings you back to the abandoned-plugin problem.
- **Drupal** has almost no commercial module market; nearly everything on drupal.org is free. The cost is people: building content types, views and permissions well takes a developer, and so does keeping a Composer-managed site current. A Drupal site with no developer budget behind it is a liability.
- **Joomla** has a larger share of commercial extensions than WordPress, often from small vendors, sold as subscriptions. Check how long a vendor has been releasing updates before you build a site around their extension.

Count your own time too. An hour a month of updates and checks is the realistic minimum for any of them, and the hour you skip is the one that matters.

#### Hosting any of them on nginx

All three ship rules for Apache in an `.htaccess` file (Joomla as `htaccess.txt`, to be renamed). nginx does not read `.htaccess` at all. On nginx the same rules - pretty URLs and, just as important, the rules that deny direct access to private files - must exist in the server configuration instead. The core of it for all three is the front-controller pattern:

```nginx
location / {
    try_files $uri $uri/ /index.php?$args;
}

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass php-fpm;
}
```

Drupal also needs its `settings.php` and private files directory kept away from the web, and Joomla's security rules from `htaccess.txt` translated. A host that sells nginx-based CMS hosting should have these in place; if pretty permalinks give you 404s on a fresh install, it is this.

On RE:NODE the CMS line - called Engines in the catalogue - runs on nginx and PHP, and you install the CMS yourself through the file manager or SFTP. There is no one-click installer, which also means nobody pins you to a version. Each plan carries database slots for the site's database and a proxy slot: point an A record at the address the panel shows and the certificate is issued and renewed automatically.

#### Choosing: a quick decision guide

- **A blog, a small business site, a portfolio, a WooCommerce shop**: WordPress.
- **A site where non-technical staff will do all the editing and you will not have a developer**: WordPress, with as few plugins as you can manage.
- **Many content types with relationships, complex roles, editorial approval, several languages, and a developer on hand**: Drupal.
- **A multilingual or membership site without a developer, where you prefer core features to plugins**: Joomla.
- **A Russian-language news or media portal with an existing DataLife Engine licence**: DLE, covered in [DataLife Engine hosting](https://www.renode.space/blog/dle-datalife-engine-hosting).
- **A site that rarely changes and has no logins or forms**: possibly no CMS at all - a static site is faster, cheaper and has nothing to patch. See [static site hosting](https://www.renode.space/blog/static-site-hosting-guide).

The worst choice is the one nobody on the team can maintain. A well-kept WordPress site beats a neglected Drupal site on security, speed and cost every time.

#### FAQ

##### Is WordPress less secure than Drupal or Joomla?

WordPress core has a good security record. It is attacked more because it is everywhere, and most real compromises come through outdated or abandoned plugins. Drupal's reputation for security comes partly from its security team and partly from sites having fewer, better-reviewed modules. An updated WordPress site with few plugins is not meaningfully less secure.

##### Can I move from one CMS to another later?

Yes, but it is a migration project, not a button. Content can be moved with importers and migration tools - Drupal's Migrate API is strong, and WordPress has importers for many sources - but themes, URLs, forms and extension data have to be rebuilt. Choose with a few years in mind.

##### Which CMS is fastest?

With page caching on, all three serve anonymous visitors quickly, because the cached page skips PHP and the database. The differences appear for logged-in users and uncached pages, where extension count and quality matter more than the CMS. Drupal's built-in caches give it a good default; WordPress needs a caching plugin or server-side cache - see [WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching).

##### Do they all work with MySQL 8.4?

Current releases of all three support MySQL 8.0 and later, and MySQL 8.4 is in that series. Older versions - Drupal 7, Joomla 3, very old WordPress plugins - may use removed SQL features or old authentication, which is one more reason to update before you move hosts.

##### Can I host several CMS sites on one plan?

Technically yes, if storage, memory and database slots allow. It is usually better to keep unrelated sites apart, so one site's compromised plugin cannot reach another site's files and database.

The CMS line on RE:NODE gives you nginx and PHP with SFTP and a file manager to install WordPress, Drupal or Joomla yourself, database slots for the site's data, and a proxy slot that issues and renews the certificate once your A record points at it.

[See CMS hosting plans](https://www.renode.space/web-hosting#cms-hosting)

---

### DataLife Engine (DLE) hosting: requirements and setup

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/dle-datalife-engine-hosting

DataLife Engine is a commercial PHP content management system built for news sites, media portals and blogs, popular mainly in Russian-speaking countries. Hosting it is the familiar PHP routine - files in a web root, a MySQL-family database, an installer in the browser - with three things that are specific to DLE: you need a licence for each domain (or the limited free demo), friendly URLs depend on rewrite rules written for Apache, and the support community and most documentation are in Russian.

According to the official requirements page, current versions need PHP 8.0 or newer, MySQL 5.6+ or MariaDB 10.0+, nginx or Apache, and the PHP extensions `zlib`, `xml`, `gd`, `curl`, `mbstring`, `fileinfo`, `exif` and `intl`. Those figures are minimums; real sites should run a current PHP 8 release and a current database. This guide goes through the licence, the install, the configuration files, nginx, caching (including Redis-protocol caching on Valkey), updates and the problems people actually report.

#### What DLE is, and the licence

DLE is developed by SoftNews Media Group and sold from its official site, `dle-news.ru`. It is closed-source commercial software in the sense that matters for hosting: you cannot download a full version freely, and the licence is tied to a domain.

As the official site describes it at the time of writing, there are three ways to run it:

| Option | What you get | Limits |
| --- | --- | --- |
| Demo version | Free, for evaluation | Encoded source, 100 news items, 200 comments, no updates or support |
| Basic licence | Full source code, updates for a year | No technical support; removing the copyright notice is not permitted |
| Extended licence | As basic, plus support | - |

Each licence covers one domain; a second site needs a second licence. Prices and terms change, so read the current [purchase page](https://dle-news.ru/price.html) before buying rather than trusting any figure in a blog post, this one included. The demo has no time limit, which makes it a reasonable way to check that DLE runs on a given hosting plan before paying.

One warning deserves its own paragraph. Because DLE is commercial and popular, "nulled" copies - pirated versions with the licence check removed - are everywhere, often bundled with templates and modules on download sites. They are the single most common way DLE sites end up compromised: the people who remove a licence check are rarely shy about adding a backdoor. Install only a version downloaded from your account on the official site, and buy third-party modules and templates from their developers.

#### Requirements and sizing

The [official requirements page](https://dle-news.ru/request.html) is the authority and is updated with releases; check it for the version you are installing. In summary:

| Requirement | Official minimum | Sensible choice in 2026 |
| --- | --- | --- |
| PHP | 8.0 | A currently supported PHP 8 release your DLE version and modules are tested on |
| Database | MySQL 5.6+ or MariaDB 10.0+ | MySQL 8.x or a current MariaDB |
| Web server | nginx or Apache | Either; nginx needs rewrite rules translated |
| PHP extensions | `zlib`, `xml`, `gd`, `curl`, `mbstring`, `fileinfo`, `exif`, `intl` | Plus OPcache enabled |
| Memory | The page quotes a very low minimum | 512 MB to 1 GB for a real site |

The low official memory figure describes what the engine itself needs per request, not a site. A news portal with thousands of articles, comments, a busy front page and a few modules is a different matter: PHP workers, the database's own memory and the cache all share the plan. As a rough guide:

| Site | Memory | CPU | Notes |
| --- | --- | --- | --- |
| New site, a few hundred articles | 1 GB | 0.5-1 core | Comfortable with file caching |
| Established news site, tens of thousands of articles | 2-4 GB | 1-2 cores | Turn caching on; watch slow queries |
| High-traffic portal with many comments | 4 GB+ | 2+ cores | Separate cache server, tuned database |

Disk use is dominated by `uploads/` - images attached to news items - and by backups kept on the server. Keep an eye on both. [How much RAM WordPress needs](https://www.renode.space/blog/how-much-ram-does-wordpress-need) walks through the same arithmetic for another PHP CMS, and it applies here almost unchanged.

Before uploading anything, confirm the PHP version and extensions your hosting actually provides. A file containing `<?php phpinfo();` in the web root, loaded once and then deleted, lists both. The official FAQ notes that an `install.php` showing only "Internal Server Error" usually means a required PHP module is missing.

#### Installing DLE

1. **Create the database** and a user with full rights on it. Write down the host, port, database name, user and password.
2. **Upload the site files.** The distribution separates the files that belong on the server from the documentation; upload the contents of the site folder into your web root, not the folder itself, or the site will only answer under a subdirectory. Upload the archive and unpack it with the file manager rather than sending thousands of files over SFTP one by one - [SFTP and the file manager](https://www.renode.space/blog/sftp-and-the-file-manager) covers both routes.
3. **Check permissions.** The installer tests that DLE can write to the directories it needs - among them `backup/`, `uploads/` and its subfolders, `templates/`, `engine/data/` and `engine/cache/`. Fix anything it flags. On container-based hosting, where PHP and the file manager run as the same user, this usually passes without changes.
4. **Run the installer** at `https://example.com/install.php`. Accept the licence, let it check the environment, enter the database details and create the administrator account. Use a non-obvious username and a generated password.
5. **Delete `install.php`** when the installer tells you to. A leftover installer is an invitation to reinstall over your site.
6. **Activate the licence** following the instructions in your account on the official site, if you bought one.

The administration panel is at `/admin.php`. DLE supports renaming that file to something less obvious, which cuts out a lot of automated login attempts; follow the steps for your version, because the panel's own links must be updated to match.

On RE:NODE, DLE is one of the systems the Engines plans are sized for. You install it yourself, through SFTP or the file manager - there is no one-click installer - and each plan carries two database slots created from the panel with a generated host, user and password, so the database exists before you start step 1.

#### The configuration files

DLE keeps its settings in PHP files under `engine/data/`. Two matter most.

**`engine/data/dbconfig.php`** holds the database connection, written by the installer:

engine/data/dbconfig.php:

```php
define ("DBHOST", "db.example.internal:3306");
define ("DBNAME", "dle");
define ("DBUSER", "dle_site");
define ("DBPASS", "from-the-panel");
define ("PREFIX", "dle");
define ("USERPREFIX", "dle");
define ("COLLATE", "utf8mb4");
```

When you move the site to a new server, this is the file to edit. As with any PHP application, `localhost` is only right when the database runs on the same machine; otherwise use the hostname you were given, with the port if it is not the default. Check the exact format your version writes before editing it by hand - the installer's own output is the template.

**`engine/data/config.php`** holds the settings you change in the admin panel, under System Settings: site address, language, time zone, friendly URLs, caching and much more. Change them through the panel where possible; the panel writes the file. If a bad setting - most often a wrong site address - locks you out, this is where you fix it by hand.

Both files contain secrets or settings that reveal how the site works. They are PHP, so a correctly configured server executes them rather than displaying them; never leave copies with other extensions, such as `dbconfig.php.bak`, which would be served as plain text.

Character set matters for DLE sites with history. Older sites were often built in `windows-1251`; current versions use UTF-8. Moving an old site means converting both the database and the templates, and getting it wrong produces the familiar garbled Cyrillic. Do the conversion as a separate, tested step - never as a side effect of a server move.

#### Friendly URLs on nginx

DLE's search-engine-friendly URLs - `/news/12-site-launch.html` instead of `index.php?newsid=12` - are produced by rewrite rules in the `.htaccess` file shipped with the engine. On Apache with `mod_rewrite`, they just work.

nginx ignores `.htaccess`. Friendly URLs on nginx need those rules translated into nginx `location` and `rewrite` directives, and DLE's rule set is long because each URL type (news, categories, static pages, tags, user profiles, RSS) has its own pattern. Two cautions apply:

- **Translate the rules from your own version's `.htaccess`**, not from a snippet copied from a forum post about a different release. The patterns change between versions.
- **Make sure rewritten requests still reach PHP.** A common failure, reported often on DLE and nginx forums, is a translated rule that ends up serving `index.php` as a download instead of executing it. Rewrites must lead into the location that passes `.php` requests to PHP-FPM.

At minimum, the front-controller fallback every PHP CMS uses should be present:

```nginx
location / {
    try_files $uri $uri/ /index.php?$args;
}
```

That alone does not make DLE's friendly URLs work, because DLE maps paths to specific query parameters. If you cannot edit the nginx configuration on your hosting, switch friendly URLs off in System Settings - the site then uses `index.php?...` addresses, which work everywhere - and ask your host whether the DLE rules can be added. Turning friendly URLs on or off after a site is indexed changes every address, so decide before launch.

The certificate and domain come first, before you set the site address in the panel: on RE:NODE, the proxy slot issues and renews the certificate once your A record points at it. [Your domain and its certificate](https://www.renode.space/blog/your-domain-and-its-certificate) covers the DNS side.

#### Caching, including Redis on Valkey

DLE caches generated blocks and pages, and on a busy news site caching is the difference between a database that copes and one that does not. The settings are in the admin panel under System Settings, on the Optimisation tab: switch caching on, then choose the cache type.

| Cache type | Needs | Good for |
| --- | --- | --- |
| Files | Nothing - works on any hosting | Small and medium sites |
| Memcache | A Memcached server and the PHP extension | Larger sites, if available |
| Redis | A Redis-protocol server and the `php-redis` extension | Larger sites; supports a password |

Redis caching arrived in DLE 14.2 and lets you enter the server address, port and authentication details in the same panel. Valkey speaks the same protocol as Redis, so a [Valkey server](https://www.renode.space/database-hosting#valkey) works as the Redis cache backend. The precondition is the PHP extension: if `phpinfo()` shows no `redis` section, the Redis option cannot work on that hosting, whatever you configure in the panel. File caching is the right choice in that case, and it is perfectly adequate for most sites.

Two habits keep caching from causing confusion. After changing templates, clear the cache from the admin panel so visitors do not see old output. And size the cache honestly: with an external cache, DLE stores blocks that are rebuilt on a miss, so an evicting policy on the Valkey side is appropriate - [Valkey memory and eviction policies](https://www.renode.space/blog/valkey-memory-and-eviction-policies) explains the options.

#### Backups and updates

DLE includes a database backup tool in the admin panel, writing dumps into the `backup/` directory. It is useful and not sufficient on its own: the dumps sit on the same disk as the site, and they do not include `uploads/`, templates or configuration. A real backup is the database plus the whole file tree, stored somewhere else, and restored occasionally to prove it works - [backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore) is the case for that last step.

Updates come as new releases on the official site, with an update procedure that replaces the engine files and runs a script that upgrades the database structure. Follow the instructions shipped with each release exactly, and before every update:

1. Take a full backup of files and database, and check it completed.
2. Note every file you changed by hand - templates, core files edited for a module. An update overwrites core files, and your edits go with them.
3. Check that each third-party module supports the new version. Modules that patch core files are the usual reason an update breaks a site.
4. Update a copy of the site first if you can; on hosting with two database slots, a staging copy with its own database costs nothing extra.
5. Apply the update, clear the cache, and test publishing, comments and login.

The basic licence includes updates for a year. After that, the version you have keeps working, but you stop receiving new releases - including security fixes - until you renew. Factor that into the cost of running DLE.

#### Troubleshooting

**`install.php` shows "Internal Server Error".** A missing PHP extension, per the official FAQ. Compare `phpinfo()` with the requirements list.

**Database connection error after a move.** `engine/data/dbconfig.php` still has the old host or password.

**All pages except the home page return 404.** Friendly URLs are on and the rewrite rules are missing - typical on nginx. Translate the rules or switch friendly URLs off.

**Garbled Cyrillic text.** A character-set mismatch between database, connection and templates, usually after moving an old `windows-1251` site.

**Admin panel redirects to the old domain.** The site address in `engine/data/config.php` was not updated after a domain change.

**The Redis cache option does nothing.** The `php-redis` extension is not loaded, or the address, port or password is wrong.

#### FAQ

##### Can I use DLE for free?

Only the demo version, which is limited to 100 news items and 200 comments, has encoded source and receives no updates or support. A real site needs a paid licence for its domain. Avoid "free full versions" from download sites; they are pirated and frequently backdoored.

##### Does DLE work on nginx?

Yes - the official requirements list nginx alongside Apache. The only difference is friendly URLs, whose rules ship in Apache's `.htaccess` format and must be translated for nginx, or friendly URLs switched off.

##### Can I move a DLE site to a different domain?

The licence is per domain, so contact the developer about moving it before you switch. Technically, the move means updating the site address in the configuration and replacing old domain references in the database, after a full backup.

##### Which PHP version should I use for DLE?

The newest PHP 8 release that your DLE version and every installed module support. The official minimum is 8.0, which is out of support upstream; old modules are the usual reason a site is stuck on an older version.

##### Do I need a separate cache server?

Not for most sites; file caching is built in and works anywhere. A Redis-protocol cache such as Valkey helps busy sites by taking load off the disk and database, provided the hosting has the `php-redis` extension.

The Engines line is sized for a CMS such as DataLife Engine: SFTP and a file manager that unpacks the DLE archive in place, two database slots with generated credentials, a proxy slot that issues and renews your certificate, and backup slots on every tier. You install DLE yourself with your own licence.

[See Engines plans](https://www.renode.space/web-hosting#cms-hosting)

---

### Drupal hosting guide: requirements, Composer and settings

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/drupal-hosting-guide

Drupal 11 needs PHP 8.3 or newer, a MySQL 8.0, MariaDB 10.6 or PostgreSQL 16 class database, at least 128 MB of PHP memory in practice, and a web server that sends every request it cannot find as a file to `index.php`. It is installed with Composer, not by unzipping an archive, and that one fact shapes everything about hosting it: the build happens wherever Composer runs, and the result - including a `vendor` directory of a few thousand files - goes onto the server.

On hosting with a shell, that is two commands. On panel hosting without one, you build the site on your own machine and upload it, which works perfectly well once you know where the web root goes. This guide covers both, then `settings.php` line by line, the nginx rules, cron, caching (including Valkey), updates and the errors people actually hit.

#### Which Drupal, and what it requires

There are three major versions in play in late 2026, and only one is the right choice for a new site.

| Version | Status | PHP |
| --- | --- | --- |
| Drupal 10 | Security support ends 9 December 2026 | 8.1 to 8.4 |
| Drupal 11 | Current, the one to install | 8.3 to 8.5 (8.5 from 11.3) |
| Drupal 12 | Scheduled; check the release schedule | 8.5 |

Start new sites on Drupal 11. A Drupal 10 site should be upgraded now rather than in December; the path is 10.3 or later to 11, done module by module with the Upgrade Status module telling you what is incompatible. Requirements change with minor releases, so check the official pages before you buy anything: [PHP requirements](https://www.drupal.org/docs/getting-started/system-requirements/php-requirements) and [database requirements](https://www.drupal.org/docs/getting-started/system-requirements/database-server-requirements).

Drupal 11's requirements in practice:

| Requirement | Drupal 11 | Notes |
| --- | --- | --- |
| PHP | 8.3 or newer | 8.4 is a good choice in 2026 |
| PHP extensions | `pdo`, the PDO driver for your database, `gd`, `json`, `xml`/`dom`, `mbstring`, `openssl`, `curl` | `gd` (or ImageMagick) for image styles |
| `memory_limit` | 64 MB minimum, 128-256 MB in practice | Media-heavy sites and big migrations need more |
| Database | MySQL 8.0+, MariaDB 10.6+, PostgreSQL 16+, SQLite 3.45+ | InnoDB for MySQL-family; `pg_trgm` for PostgreSQL |
| Disk | 300-500 MB for code | Plus `sites/default/files`, which is what grows |
| Web server | nginx or Apache with rewrites | nginx ignores `.htaccess` |

Memory deserves a word. A Drupal site with a couple of dozen contributed modules uses more PHP memory per request than an equivalent WordPress site, and rebuilding caches after a deploy is the heaviest moment. A 1 GB plan runs a small Drupal site; 2 GB is the comfortable starting point for anything with Views-heavy pages, Media and Search API, and the PHP `memory_limit` should be 256 MB on those. [The php.ini settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers `memory_limit`, `max_execution_time` and OPcache.

There is also Drupal CMS, a packaged distribution on top of Drupal core aimed at site builders, with recipes for common features preconfigured. It installs and is hosted the same way; everything here applies to it.

#### Building the codebase with Composer

The supported starting point is the `drupal/recommended-project` template:

```bash
$ composer create-project drupal/recommended-project example.com
$ cd example.com
$ composer require drush/drush
$ composer require drupal/admin_toolbar drupal/pathauto drupal/redis
```

The result has a specific layout, and understanding it prevents most hosting problems:

```text
example.com/
  composer.json        what you asked for
  composer.lock        exact versions installed - commit this
  vendor/              PHP libraries, Drush, Symfony - not web-accessible
  web/                 the web root
    index.php
    core/
    modules/contrib/
    themes/contrib/
    sites/default/
```

Only `web/` should be served. `vendor/` and `composer.json` sit beside it, outside the web root, so nobody can request them over HTTP. This is the main structural difference from WordPress or Joomla, where everything lives in the web root.

Keep `composer.json` and `composer.lock` in version control, along with your custom modules and themes, and treat `vendor/`, `web/core` and `web/modules/contrib` as build output. Commit `composer.lock` always - it is what makes a build on your laptop and a build in six months produce the same site.

#### Installing on hosting without a shell

Panel hosting often gives you a file manager and SFTP but no login shell, so Composer cannot run on the server. That is not a blocker. Build locally, then upload the build.

1. **Match PHP versions.** Run the same major and minor PHP version locally as on the server. Composer resolves dependencies for the PHP it runs under, and a lock file built on PHP 8.4 can pull packages that refuse to run on 8.3. Pin it in `composer.json` with `"config": { "platform": { "php": "8.3.0" } }` if your machine runs something newer.
2. **Build without development packages:** `composer install --no-dev --optimize-autoloader`.
3. **Decide where the web root goes.** If you can upload the whole project and point the site at `web/`, do that. If the host serves a fixed directory - `public_html`, `www`, `public` - rename Drupal's web root to match instead of moving files around: in `composer.json`, change every `web/` path under `extra.installer-paths` and set `extra.drupal-scaffold.locations.web-root` to the directory name, then run `composer install` again. Upload the project one level above that directory, so `vendor` stays outside it.
4. **Upload as one archive.** Thousands of small files over SFTP take far longer than one zip. A file manager that unpacks archives in place turns that into a single upload; [SFTP and the file manager](https://www.renode.space/blog/sftp-and-the-file-manager) covers both routes.
5. **Create the database and user**, then visit the site and run the installer.

If the host only lets you write inside the web root, you can still run Drupal with `vendor` inside it, but then you must make sure the web server refuses requests for `vendor/`, `composer.json` and `composer.lock` - those files reveal exact versions of everything you run. A layout with the web root one level down is better if you can get it.

On RE:NODE the Engines plans are the line sized for a CMS like this. You install Drupal yourself through SFTP or the file manager - there is no one-click installer, and so nobody else pins your version - and each plan carries two database slots created from the panel with a generated host, user and password. A Drupal site needs one of them; the second is there for a staging copy.

#### settings.php, line by line

The installer copies `sites/default/default.settings.php` to `settings.php` and writes the database credentials into it. Several settings should be added or checked by hand.

web/sites/default/settings.php:

```php
$databases['default']['default'] = [
  'driver'    => 'mysql',
  'database'  => 'drupal',
  'username'  => 'drupal_site',
  'password'  => getenv('DRUPAL_DB_PASSWORD') ?: 'from-the-panel',
  'host'      => 'db.example.internal',
  'port'      => '3306',
  'prefix'    => '',
  'collation' => 'utf8mb4_general_ci',
  'init_commands' => [
    'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED',
  ],
];

$settings['hash_salt'] = 'a-long-random-string';
$settings['config_sync_directory'] = '../config/sync';
$settings['file_private_path'] = '../private';
$settings['trusted_host_patterns'] = ['^example\.com$', '^www\.example\.com$'];
$settings['update_free_access'] = FALSE;
```

What each line is for:

- **`host` and `port`** are where people go wrong, exactly as with WordPress. `localhost` only works when the database runs on the same machine; with a database slot or a separate database server, use the hostname and port you were given.
- **`init_commands` with `READ COMMITTED`** applies to MySQL-family databases. Drupal's status report recommends it, because the default `REPEATABLE READ` level causes more deadlocks under Drupal's write patterns. Newer installers may write an equivalent `isolation_level` key themselves; one of the two is enough.
- **`hash_salt`** is used for one-time login links, form tokens and other signatures. The installer generates one; keep it secret and identical across every server running the same site.
- **`config_sync_directory`** is where configuration is exported to and imported from. Put it outside the web root, as shown, so exported configuration is not downloadable.
- **`file_private_path`** enables private files, served only through Drupal's access checks. Also outside the web root.
- **`trusted_host_patterns`** is a security setting, not an optional one. Without it, Drupal accepts any `Host` header and can be tricked into generating links to an attacker's domain in password-reset emails. The status report warns until it is set.

After installation, make `settings.php` and the `sites/default` directory read-only. Drupal checks this and complains in the status report if it can write to them.

**Behind a reverse proxy**, Drupal needs to be told so before it will trust `X-Forwarded-For` and `X-Forwarded-Proto`:

web/sites/default/settings.php:

```php
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['192.0.2.10'];
```

The addresses are those your proxy connects from. Without this, every visitor appears to come from the proxy, flood control locks everyone out together, and generated URLs may use `http://` on an HTTPS site. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the headers.

#### nginx rules

Drupal ships an `.htaccess` file with its rewrite rules and a set of protections - denying access to `.yml`, `.twig`, `composer.json` and similar files. nginx ignores `.htaccess` entirely, so those protections must be recreated in the server configuration. The core of it:

```nginx
location / {
    try_files $uri /index.php?$query_string;
}

location @rewrite {
    rewrite ^ /index.php;
}

location ~ ^/sites/.*/files/styles/ {
    try_files $uri @rewrite;
}

location ~ (^|/)\. { return 403; }
location ~ ^/sites/.*/private/ { return 403; }
location ~* \.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|tpl|yml|yaml)$ {
    return 403;
}
location ~ /vendor/ { deny all; }
```

The `styles` block matters more than it looks: image style derivatives are generated on the first request, so a missing file there must go to Drupal rather than return a 404. Without it, every resized image on the site is broken. Compare the full list of protections with the `.htaccess` file in your own `web/` directory, which is the authoritative list for your Drupal version.

On managed hosting you may not edit the nginx configuration yourself. Check whether the standard front-controller rule (`try_files ... /index.php`) is already in place - if `/user/login` works, it is. If image styles or private files misbehave, that is the part to ask your host about.

#### Cron and background work

Drupal needs cron for search indexing, clearing expired caches and sessions, update checks, queue processing and anything modules schedule. There are three ways to run it.

- **Automated Cron**, a core module, runs cron at the end of a page request when enough time has passed - every three hours by default, configurable at `/admin/config/system/cron`. Fine for small sites; the visitor who triggers it gets a slower response.
- **The cron URL**, shown on the same page as `https://example.com/cron/<key>`. Any external scheduler can request it - a monitoring service, a scheduled job on another machine. Keep the key secret; anyone with the URL can trigger cron.
- **Drush**, `drush cron`, from a system crontab where you have a shell. The most reliable option, and the one that does not tie cron to web traffic.

If you use an external trigger, turn Automated Cron off so cron does not also run inside visitors' requests. [Cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) covers the schedule syntax.

#### Caching, and adding Valkey

Drupal's core caching is good and switched on by default:

- **Internal Page Cache** stores whole pages for anonymous visitors.
- **Dynamic Page Cache** caches the parts of pages that are the same for many users, including logged-in ones, using cache contexts.
- **BigPipe** sends the cached page shell first and streams personalised parts afterwards.
- **CSS and JavaScript aggregation**, under Configuration, then Performance, should be on in production.

By default all cache bins live in database tables. On a busy site that is a lot of database traffic for data that does not need durability, and moving the cache bins to Valkey is the standard improvement. The `drupal/redis` module speaks the Redis protocol and connects to Valkey unchanged. Enable the module first, then add to `settings.php`:

web/sites/default/settings.php:

```php
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = '203.0.113.20';
$settings['redis.connection']['port'] = 6380;
$settings['redis.connection']['password'] = getenv('VALKEY_PASSWORD');
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache_prefix'] = 'example_com_';
$settings['container_yamls'][] = 'modules/contrib/redis/example.services.yml';
```

`PhpRedis` needs the phpredis extension; `Predis` is the pure-PHP alternative, installed with `composer require predis/predis`. Set a unique `cache_prefix` per site if several share one Valkey. The database stays the source of truth: the cache can be flushed at any time with `drush cr` or the Clear all caches button. A [Valkey plan](https://www.renode.space/database-hosting#valkey) is sized from 256 MB, which is plenty for most Drupal cache bins; [Valkey memory and eviction policies](https://www.renode.space/blog/valkey-memory-and-eviction-policies) explains why an evicting policy suits a cache-only instance.

#### Updates and configuration deploys

Drupal releases security updates on a published schedule, normally on Wednesdays. Security releases for core and popular modules are announced on drupal.org, and the Update Status module emails you when your site is behind. Read the advisories; a "highly critical" core release, such as the 2018 Drupalgeddon issues, is exploited within hours.

The update itself, with Composer:

```bash
$ composer update "drupal/core-*" --with-all-dependencies
$ drush updatedb
$ drush cache:rebuild
```

Without a shell, run the `composer update` locally, upload the changed `vendor/` and `web/core/` (and any updated modules), then visit `/update.php` as an administrator to run database updates. Take a backup first, every time, and put the site in maintenance mode during the update so nobody writes to the database mid-way. [Backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore) is the argument for testing that backup.

Configuration changes - content types, fields, views - should travel from development to production through configuration management, not be clicked twice: `drush config:export` on the development copy, commit the YAML files, deploy, `drush config:import` on production. The UI equivalent lives under Configuration, then Development, then Configuration synchronisation.

#### Troubleshooting

**The installer stops at "Requirements problem".** Read the list: usually a missing PHP extension, a PHP version below 8.3 for Drupal 11, or `sites/default/files` not writable.

**"The provided host name is not valid for this server".** `trusted_host_patterns` does not match the hostname you are visiting. Add it, including `www` if used.

**White screen or "The website encountered an unexpected error".** Check the recent log messages at `/admin/reports/dblog`, or the PHP error log if the admin is unreachable. Memory exhaustion during cache rebuilds is common; raise `memory_limit`.

**Every page except the front page returns 404.** The front-controller rewrite is missing - typical on nginx when only Apache's `.htaccess` was relied on.

**Images in image styles are broken.** The `styles` rule above is missing, or `sites/default/files` is not writable.

**Redirect loops after enabling HTTPS.** The proxy headers are not trusted; set `reverse_proxy` and its addresses.

#### FAQ

##### Can I install Drupal without Composer?

Not in a supported way for Drupal 11. Archive downloads of core exist, but contributed modules declare their dependencies through Composer, and managing them by hand breaks on the first module with a library dependency. Build with Composer on any machine, then upload the result.

##### How much hosting does a Drupal site need?

A small brochure site runs on 1 GB with one or two CPU cores. A site with Views-heavy pages, Media, Search API and some logged-in traffic wants 2 GB or more and a PHP `memory_limit` of 256 MB. Memory and PHP worker count are usually the limit before CPU.

##### Is Drupal harder to host than WordPress?

It asks more of the build step - Composer, a separate web root, configuration management - and slightly more memory. In exchange, its caching and structured content scale further before you need to add anything. On the hosting side, both are PHP applications with a database.

##### When do I have to leave Drupal 10?

Drupal 10 security support ends on 9 December 2026. Upgrade to Drupal 11 before then; sites still on Drupal 10 after that date get no security fixes.

##### Do I need Valkey for Drupal?

No. Drupal's database-backed caches work at small and medium scale. Moving cache bins to Valkey takes load off the database and helps busy sites, especially ones with many logged-in users.

The Engines line is sized for a CMS such as Drupal: SFTP and a file manager that unpacks your Composer build in place, two database slots with generated credentials, a proxy slot that issues and renews the certificate for your domain, and backup slots on every tier. You install the Drupal version you choose.

[See Engines plans](https://www.renode.space/web-hosting#cms-hosting)

---

### Joomla hosting guide: install, configuration.php, updates

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/joomla-hosting-guide

Joomla 6 needs PHP 8.3 or newer (8.4 recommended), a MySQL 8.0.13+, MariaDB or PostgreSQL database, a PHP `memory_limit` of 256 MB, and the usual handful of PHP extensions. Installation is the classic PHP routine: upload one archive, unpack it in the web root, create an empty database, and walk through a browser installer that writes `configuration.php` for you. On a normal connection the whole job is fifteen minutes.

The parts that cause trouble later are elsewhere: search-engine-friendly URLs on nginx, where Joomla's `.htaccess` does nothing; extensions installed from wherever a search result pointed; caching and session settings left on defaults that do not fit the site; and updates applied without a backup. This guide does the install and then each of those properly.

#### Joomla 6 or Joomla 5.4

Joomla 6.0 and 5.4 were released together in October 2025. Joomla 6 is the current major version; 5.4 is the last of the 5 series, which moved to maintenance. According to the project's published roadmap, Joomla 5 receives bug fixes until October 2026 and security fixes until October 2027 - confirm the current dates on joomla.org before you plan around them.

For a new site, install Joomla 6. Use 5.4 only if an extension you cannot do without has not been updated for 6 yet, and plan the move. Moving from 5.4 to 6 is an in-place update through the Joomla Update component once the Pre-Update Check reports that your extensions are compatible; it is far smaller than the jump from Joomla 3 to 4 was.

Joomla 6 requirements, from the [official technical requirements page](https://manual.joomla.org/docs/get-started/technical-requirements/):

| Software | Minimum | Recommended |
| --- | --- | --- |
| PHP | 8.3.0 | 8.4 |
| MySQL | 8.0.13 | 8.4 |
| MariaDB | 10.4 (10.6 supported) | 12.0 |
| PostgreSQL | 12.0 (14.0 supported) | 17.6 |
| nginx | 1.26 supported | 1.29 |
| Apache | 2.4 | 2.4 |

Required PHP extensions are `json`, `simplexml`, `dom`, `zlib`, `gd` and one of `mysqlnd`, `pdo_mysql` or `pdo_pgsql`; `mbstring` is recommended. The manual recommends a `memory_limit` of at least 256 MB. Raise `upload_max_filesize` and `post_max_size` to something like 32-64 MB as well, or installing a large extension package fails with an unhelpful error. [The php.ini settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers where those live.

Resource use is moderate. A small Joomla site runs comfortably on 1 GB of memory; a site with a page builder, a shop extension and logged-in members wants 2 GB. Disk is about 100 MB for Joomla itself, plus images and whatever backup archives you keep on the server - and backup archives are what fills small disks.

#### Before you start: database and PHP version

Create the database first. Joomla does not create one for you on most hosting; it needs an existing empty database and a user with full rights on it. Write down the host, port, database name, user and password.

On RE:NODE, the Engines plans are the line sized for a CMS like Joomla, and each carries two database slots created from the panel with a generated host, user and password. The **Open in phpMyAdmin** button signs you in with a single-use token that expires after sixty seconds, so you can check the database is there and empty without typing the password into a form. You install Joomla yourself - there is no one-click installer, which also means nobody else decides when your version changes.

Check the PHP version your plan actually runs before downloading anything: a file containing `<?php phpinfo();`, loaded once in the browser and then deleted, shows the version, the loaded extensions and the effective `memory_limit`. Joomla 6 will refuse to install on PHP 8.2.

#### Installing Joomla

1. **Download** the full package from `downloads.joomla.org`, never from a third-party mirror. A tampered CMS archive is the most direct way to start life compromised.
2. **Upload the zip to the web root** and unpack it in place. A file manager that extracts server-side turns several thousand small files into one upload. Over SFTP, upload the zip and extract it in the file manager, rather than uploading the extracted tree. [SFTP and the file manager](https://www.renode.space/blog/sftp-and-the-file-manager) covers both.
3. **Visit the site.** The installer starts automatically. Choose the language and set the site name.
4. **Create the Super User.** Not `admin`; a long generated password; a real email address you read.
5. **Database settings.** Type `MySQLi` or `MySQL (PDO)` for MySQL and MariaDB, `PostgreSQL (PDO)` for PostgreSQL. Host, user, password and database name from your panel. Keep the random table prefix the installer suggests.
6. **Prove you own the site, if asked.** When the database host is anything other than `localhost`, Joomla creates a file with a random name beginning `_Joomla` in the `installation` folder and asks you to delete it before it continues. This stops someone who finds a half-finished install from pointing it at their own database. Delete it with the file manager and click Install again.
7. **Finish.** The last screen offers to open the site or the administrator. Make sure the `installation` folder is gone afterwards; if it is still there, delete it.

The administrator lives at `/administrator`. Sign in, go to System, then Global Configuration, and work through the settings below before you add any content.

#### configuration.php and Global Configuration

Everything you set in Global Configuration is stored in `configuration.php` in the site root, as properties of a PHP class. You can edit the file directly - useful when a wrong setting has locked you out of the administrator - but make it writable first, edit carefully, and make it read-only again.

configuration.php:

```php
public $sitename = 'Example';
public $offline = false;
public $dbtype = 'mysqli';
public $host = 'db.example.internal:3306';
public $user = 'joomla_site';
public $password = 'from-the-panel';
public $db = 'joomla';
public $dbprefix = 'x7k2q_';
public $secret = 'generated-at-install';
public $live_site = '';
public $force_ssl = 2;
public $sef = true;
public $sef_rewrite = true;
public $tmp_path = '/path/to/site/tmp';
public $log_path = '/path/to/site/administrator/logs';
public $error_reporting = 'none';
public $debug = false;
public $caching = 0;
public $cache_handler = 'file';
public $lifetime = 30;
public $session_handler = 'database';
public $behind_loadbalancer = true;
```

The ones that matter:

- **`host`** takes a hostname with the port after a colon when the database is not on the default port. As with every PHP CMS, `localhost` is only right when the database runs on the same machine.
- **`secret`** is generated at install and used in hashing and tokens. Do not copy it between sites; do keep it when you move one.
- **`force_ssl`**: `0` none, `1` administrator only, `2` the entire site. Use `2` once HTTPS works, and not before - setting it on a site without a certificate locks you out, and the fix is editing this file.
- **`tmp_path` and `log_path`** are absolute paths. After moving a site to a new server these are the classic cause of "cannot install extension" errors, because they still point at the old server's directories. Update them after every migration.
- **`error_reporting`**: `none` or `simple` in production. `maximum` or `development` only while debugging, because it prints paths and queries to visitors.
- **`lifetime`** is the session lifetime in minutes, 15 by default. Administrators tired of being logged out raise it; 30 to 60 is reasonable.
- **`behind_loadbalancer`**, labelled Behind Load Balancer under the Server tab, tells Joomla to trust `X-Forwarded-For` and `X-Forwarded-Proto`. Turn it on when a reverse proxy terminates TLS in front of the site, or Joomla sees every visitor as the proxy and may build `http://` URLs on an HTTPS site.

#### SEF URLs on nginx

Search Engine Friendly URLs come in two parts. `sef` turns `index.php?option=com_content&view=article&id=12` into `index.php/my-article`. `sef_rewrite` removes the `index.php/` - and that needs the web server to send unknown paths to `index.php`.

On Apache, Joomla ships the rules in `htaccess.txt`; rename it to `.htaccess`. On nginx, `.htaccess` is ignored, and the equivalent is:

```nginx
location / {
    try_files $uri $uri/ /index.php?$args;
}
```

If you switch on Use URL Rewriting and every page except the home page returns a 404, this rule is missing. On managed hosting you may not edit the nginx configuration yourself: test by turning rewriting on and visiting an article - if it loads, the rule is already there. If not, leave `sef_rewrite` off (URLs keep the `index.php/` segment but work) and ask the host. A domain and certificate in front of the site come next; on RE:NODE the proxy slot issues and renews the certificate automatically once your A record points at it, and [your domain and its certificate](https://www.renode.space/blog/your-domain-and-its-certificate) covers the DNS side.

#### Extensions: installing them safely

Joomla's extension types are components, modules, plugins, templates and language packs, all installed from System, then Install, then Extensions - by uploading a package, from a folder, from a URL or from the Install from Web tab, which browses the Joomla Extensions Directory.

Rules that keep a Joomla site maintainable:

- **Install from the developer or the JED only.** "Free" copies of commercial extensions from download sites are the most common source of backdoors in Joomla sites.
- **Check the last update date and Joomla 6 compatibility** before installing. An extension last updated three years ago will block your next major upgrade.
- **Fewer, better extensions.** Each one is code that runs on every page and an update you have to apply.
- **Commercial extensions use download keys.** Enter the key under System, then Update Sites, or the extension cannot update itself.
- **Uninstall rather than disable** what you no longer use. Disabled code is still on disk and still exploitable if it has a flaw that is reachable directly.

A backup extension is the one most sites should add on day one. Akeeba Backup is the long-standing choice: it archives files and database together, and its companion Kickstart script restores an archive onto an empty server. Store archives off the server - a backup on the same disk as the site disappears with it. [Backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore) explains why the restore test matters more than the backup.

#### Caching, sessions and scheduled tasks

Joomla has two separate caching layers, both off by default:

- **Conservative or Progressive caching**, under Global Configuration, then System, caches module and component output. Conservative is the safe choice; Progressive caches per visitor and can use a lot of disk.
- **The System - Page Cache plugin** caches whole pages for guests. Large gain for anonymous traffic, but exclude pages with forms or anything personalised, and leave it off on shops.

The **Cache Handler** and **Session Handler** settings list only the backends your PHP installation supports. File and database are always there. Redis appears when the phpredis extension is loaded, and works with Valkey unchanged because Valkey speaks the same protocol. Moving sessions and cache to a [Valkey server](https://www.renode.space/database-hosting#valkey) takes write load off the database on busy sites; on small sites the database handler is fine. If the Redis option is missing from the list, the extension is not installed, and no setting will add it.

Joomla's **Task Scheduler**, under System, then Scheduled Tasks, runs jobs such as session cleanup, sending queued emails, update notifications and site checks. It is triggered one of three ways: the Lazy Scheduler, which runs due tasks during normal page visits (the default, fine for small sites); a web cron URL with a secret key that an external scheduler requests; or the CLI with `php cli/joomla.php scheduler:run` where you have a shell. If nothing triggers it, tasks silently never run. [Scheduled tasks worth having](https://www.renode.space/blog/scheduled-tasks-worth-having) is a broader list.

#### Updates

Joomla 5.4 and 6.0 introduced automatic core updates. On a fresh Joomla 6 install the feature is enabled by default; on a site updated from Joomla 5 it starts disabled and must be switched on in the Joomla Update component. It applies bug-fix and security releases (6.0.0 to 6.0.1, for example) and emails Super Users the result. It relies on Joomla's update service being able to reach your site, so it does not work on local development copies.

For major and minor updates, and for every extension update, the routine should be:

1. Take a full backup, and confirm it completed.
2. Read the release notes, especially for extensions that change database structure.
3. Run the Pre-Update Check for major versions; every extension should report compatible.
4. Update core first, then extensions, under System, then Update.
5. Clear the cache and check the front end, a login and any form.
6. Look at System, then Manage, then Database. If it reports structure problems, the Fix button repairs schema differences left by an interrupted update.

#### Troubleshooting

**"Error connecting to the database" during install.** Wrong host or credentials. Test them in phpMyAdmin; if they work there, the host field is usually the difference - `localhost` where a hostname was needed, or a missing port.

**"JFolder::create: Could not create folder" or extension installs fail.** `tmp_path` points to a directory that does not exist or is not writable, usually after a migration.

**404 on every page except the home page.** URL rewriting is on without the server rule.

**Locked out after enabling Force HTTPS.** Set `force_ssl` back to `0` in `configuration.php`, fix the certificate, then turn it on again.

**"The most recent request was denied because it had an invalid security token".** An expired session; refresh and sign in again. If it happens constantly, raise `lifetime` or check that the session handler's storage is working.

**White page.** A PHP fatal error with display off. Set `error_reporting` to `maximum` briefly, reproduce, read the message, set it back.

#### FAQ

##### Is Joomla still maintained?

Yes. Joomla 6.0 was released in October 2025 with automatic core updates, and the project publishes regular minor and security releases. It has a smaller extension ecosystem than WordPress, and a capable built-in feature set - multilingual content, access levels and custom fields in core.

##### How much hosting does Joomla need?

A small site runs on 1 GB of memory and half a CPU core. A site with a page builder, a shop extension or many logged-in members wants 2 GB. PHP's `memory_limit` should be 256 MB, as the Joomla manual recommends.

##### Can I move a Joomla site to new hosting?

Yes. Copy the files and export the database, import it on the new server, then update the database credentials, `tmp_path` and `log_path` in `configuration.php`. A backup extension with a restore script does the same in fewer steps. [The phpMyAdmin import and export guide](https://www.renode.space/blog/phpmyadmin-import-export-guide) covers the database half.

##### Should I use the Redis cache handler with Valkey?

On a busy site, yes - it takes cache and session writes off the database. It needs the phpredis extension. On a small site the file cache and database sessions are simpler and perfectly adequate.

##### Which is better, Joomla or WordPress?

It depends on the site. Joomla's core does multilingual sites and fine-grained access levels without extensions; WordPress has a far larger ecosystem of themes and plugins. [WordPress, Drupal and Joomla compared](https://www.renode.space/blog/cms-hosting-wordpress-drupal-joomla-compared) goes through which suits what.

The Engines line is sized for a CMS such as Joomla: SFTP and a file manager that unpacks the Joomla archive in place, two database slots with generated credentials and one-click phpMyAdmin sign-in, a proxy slot that issues and renews your certificate, and backup slots on every tier. You install the Joomla version you choose.

[See Engines plans](https://www.renode.space/web-hosting#cms-hosting)

---

### WordPress object cache with Valkey: setup and limits

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/valkey-object-cache-for-wordpress

A persistent object cache keeps the results of WordPress's database lookups in Valkey between requests, so the next request reads them from memory instead of asking the database again. On a site with logged-in users, a busy admin area or a WooCommerce shop, it cuts database queries per page sharply and makes the admin noticeably faster. On a brochure site whose pages are already served from a page cache, it changes almost nothing - the page never reaches PHP, so there are no queries to save.

That distinction is the whole post in one sentence. The setup itself is ten minutes: a plugin, four lines in `wp-config.php`, and a button. What takes thought is knowing whether your site is the kind that benefits, sizing the cache, and recognising the handful of failure modes - stale options, a missing drop-in, a cache shared between sites by accident. This post covers all of it.

#### What the object cache is

WordPress has always had an object cache: the `WP_Object_Cache` class behind `wp_cache_get()` and `wp_cache_set()`. Core uses it for options, posts, post meta, terms, users and query results, and plugins use it for their own data. By default it lives in PHP memory and dies at the end of each request. It saves repeated lookups within one page load, and nothing between page loads.

A persistent object cache replaces that class with one that stores the same data in an external server. WordPress loads the replacement from a drop-in file, `wp-content/object-cache.php`, before almost anything else. With it in place, the second request for the same post does not query the database for the post, its meta, its terms or its author; it fetches them from Valkey.

Since WordPress 6.1, the Site Health screen recommends a persistent object cache on sites it considers large enough to benefit, which is why many site owners first hear about it there.

```diagram
title: Where each WordPress cache sits in a request
visitor = Visitor
proxy = Proxy and page cache | full HTML
php = PHP and WordPress | builds the page
valkey = Valkey | object cache
db = Database | the source of truth
visitor -> proxy
proxy -> php : cache miss only
php -> valkey : options, posts, meta
php -> db : on object cache miss
```

#### What it speeds up, and what it does not

The object cache only helps requests that run PHP and query the database. So the question is which requests do that on your site.

| Workload | Page cache helps | Object cache helps |
| --- | --- | --- |
| Anonymous visitors on a blog | A lot | Little - cached pages skip PHP |
| Logged-in members or a community | Not at all - pages are personal | A lot |
| WooCommerce cart, checkout, account | Not at all - excluded from page cache | A lot |
| `wp-admin` and the block editor | Not at all | Noticeably |
| REST API and AJAX endpoints | Rarely | Yes |
| A slow query on an unindexed meta field | No | Only on repeat |
| Heavy PHP work (page builders, image processing) | Only if cached | No |

The last two rows are the uncomfortable ones. If a page is slow because a plugin runs a `meta_query` that scans a hundred thousand rows, the object cache serves the result fast the second time and just as slowly the first - including every time the cache is flushed. And if a page is slow because a page builder spends 800 ms assembling HTML in PHP, no data cache changes that. Measure before you add anything. Query Monitor shows the number and time of queries per page, and whether the object cache is hitting.

Order of operations for a slow WordPress site:

1. A page cache for anonymous traffic, which is the biggest win for most sites.
2. Fix the slowest queries and the heaviest plugins.
3. A persistent object cache for everything a page cache cannot serve.
4. Then think about more PHP workers or more memory.

[WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching) goes through the page-cache side and how the layers fit together.

#### The plugins

There are a few ways to get a drop-in that talks to Valkey. All speak the Redis protocol, so they connect to Valkey unchanged.

- **Redis Object Cache** (free, by Till Krüss) is the most widely used. It installs `object-cache.php`, shows status and hit-rate metrics in the admin, and is configured with `WP_REDIS_*` constants. It can use the phpredis extension, Relay, or Predis, a pure-PHP client bundled with it - so it works even where you cannot install PHP extensions.
- **Object Cache Pro** is the commercial version from the same author, with better serialisation and compression, more efficient group flushing and support. It is aimed at large shops and agencies.
- **W3 Total Cache** and **LiteSpeed Cache** include object-cache options alongside their page caching. Reasonable if you already use one of them; avoid running two object-cache drop-ins at once - there is only one `object-cache.php`.

This post uses Redis Object Cache, because it is free, common and well documented.

#### Setup, step by step

1. **Check that WordPress can reach Valkey.** From any machine with the client, `valkey-cli -h <host> -p <port> --askpass` then `PING` should answer `PONG`. If your WordPress runs on one server and Valkey on another, this is a network call on every cache read, so they should be in the same location. On RE:NODE both [web hosting](https://www.renode.space/web-hosting) and Valkey run in Germany, and a [Valkey plan](https://www.renode.space/database-hosting#valkey) comes with its host, port and a generated password.
2. **Check for the phpredis extension.** A file containing `<?php phpinfo();` in the web root, loaded once and then deleted, shows a `redis` section if the extension is present. If it is not, the plugin falls back to Predis, which is slower but works.
3. **Add the constants to `wp-config.php`**, above the line that says `That's all, stop editing!`:

wp-config.php:

```php
define( 'WP_REDIS_HOST', '203.0.113.20' );
define( 'WP_REDIS_PORT', 6380 );
define( 'WP_REDIS_PASSWORD', 'the-generated-password' );
define( 'WP_REDIS_DATABASE', 0 );
define( 'WP_REDIS_PREFIX', 'shop_example_com:' );
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );
define( 'WP_REDIS_MAXTTL', 86400 );
```

4. **Install and activate** Redis Object Cache from Plugins, then Add New.
5. **Enable it** under Settings, then Redis, with the Enable Object Cache button. This copies the drop-in to `wp-content/object-cache.php`. With WP-CLI, `wp redis enable` does the same and `wp redis status` reports the connection.
6. **Load a few pages and check** the status screen: it should say Connected, with hits climbing. Query Monitor should show far fewer database queries on a second load of the same admin page.

The constants worth understanding:

- **`WP_REDIS_PREFIX`** is mandatory in practice. Two WordPress sites pointed at the same Valkey without distinct prefixes share option caches and serve each other's settings - one of the most confusing bugs you can create. Use the domain.
- **`WP_REDIS_MAXTTL`** caps the lifetime of every key. Some plugins store cache entries with no expiry; without a cap they live until evicted. A day is a sensible ceiling.
- **`WP_REDIS_TIMEOUT` and `WP_REDIS_READ_TIMEOUT`** are seconds. Keep them short: if Valkey is unreachable, every page waits for the timeout before falling back.
- **`WP_REDIS_PASSWORD`** accepts an array, `[ 'username', 'password' ]`, when you use an ACL user rather than the default user.
- **`WP_REDIS_DISABLED`**, set to `true`, bypasses the cache without removing the drop-in - the emergency switch.

Keep the password out of version control if `wp-config.php` is in a repository. [Install WordPress manually](https://www.renode.space/blog/install-wordpress-manually) covers the rest of `wp-config.php`.

#### Sizing Valkey for WordPress

An object cache's size is roughly the set of objects your site touches regularly: options (including autoloaded ones), posts, meta, terms, users, transients and whatever plugins add. For most sites that is surprisingly small.

| Site | Typical object cache size | Starting plan |
| --- | --- | --- |
| Blog or brochure site, a few hundred posts | Tens of MB | 256 MB |
| Membership site or small shop, a few thousand products | 100-300 MB | 512 MB |
| WooCommerce with tens of thousands of products and orders | Several hundred MB and up | 1-2 GB |
| Multisite network with many sub-sites | Sum of the sites | Measure |

Measure rather than guess: after a day of normal traffic, `INFO memory` shows `used_memory_human`, and the plugin's status screen shows the same figure.

The right eviction policy for a Valkey used only as an object cache is `allkeys-lru` or `allkeys-lfu`: when memory fills, the least useful entries are discarded and WordPress rebuilds them from the database. With `noeviction`, a full cache makes writes fail and the plugin reports errors. If the same Valkey also holds sessions or queues for another application, that conflict is a reason to give WordPress its own instance; [Valkey memory and eviction policies](https://www.renode.space/blog/valkey-memory-and-eviction-policies) explains the trade-off.

Persistence is less important for an object cache than for anything else in Valkey, because everything in it can be rebuilt. It still helps: after a restart, a persisted cache is warm immediately instead of every page paying for a cold start at once.

#### Transients, autoloaded options and stale data

Two WordPress mechanisms change behaviour when a persistent object cache is installed, and both explain bugs people report.

**Transients move out of the database.** Without an object cache, `set_transient()` writes to the `wp_options` table. With one, transients live only in Valkey and never touch the database. That is usually an improvement - expired transients no longer pile up in `wp_options` - but it means that a plugin which stores something essential in a transient loses it when the cache is flushed or the key is evicted. Transients are meant to be disposable; a plugin that treats them as storage has a bug that the object cache exposes.

**Options are cached, including `alloptions`.** WordPress loads all autoloaded options in one go and caches them as one entry. If you change an option directly in the database - with phpMyAdmin, a migration script or a search-replace - WordPress keeps serving the cached value until the cache is flushed. That is the classic "I changed the site URL in the database and nothing happened". After any direct database edit, flush the cache from the plugin's screen or with `wp cache flush`.

A related trap is a huge `alloptions` entry. Some plugins autoload hundreds of kilobytes of options; every request then fetches that blob from Valkey, and memory per request goes up. Query Monitor and `wp option list --autoload=on --format=total_bytes` show the size. Trimming autoloaded options helps with or without an object cache.

##### Staging copies, migrations and multisite

Three situations make the cache a source of confusion rather than speed, and each has a simple rule.

**Staging copies.** Clone a site to staging, copy `wp-config.php` along with it, and the staging site now reads and writes production's cache entries - same host, same prefix. Staging's edits appear on production until the keys expire, and production's options appear on staging. Give every copy its own `WP_REDIS_PREFIX`, or its own Valkey, before the first page load. Setting `WP_ENVIRONMENT_TYPE` correctly does not change cache keys on its own.

**Migrations.** Moving a site to a new host with a search-replace of URLs is a direct database edit, so flush the object cache on the new host after the import and before testing. [Migrating WordPress to a new host](https://www.renode.space/blog/migrate-wordpress-to-a-new-host) covers the rest of the move. If you are moving the database but keeping the same Valkey, flush there too.

**Multisite.** A network shares one drop-in and one prefix; the plugin separates sub-sites by blog id and treats some groups, such as users and site metadata, as global across the network. Do not try to give each sub-site its own prefix by editing constants per site. Size the cache for the whole network, and expect a flush to affect every sub-site at once.

**Deploys.** Most code deploys need no flush at all. A deploy that changes how a plugin stores cached data - a new structure under the same key - does, and the plugin's own upgrade routine usually handles it. When a deploy produces errors that vanish after a flush, that is what happened.

#### Troubleshooting

**"Error establishing a Redis connection" or the status says Not Connected.** Check host, port and password from the WordPress server with `valkey-cli`. A connection refused means the address or port is wrong; `WRONGPASS` or `NOAUTH` means the password is.

**The site shows another site's settings or name.** Two sites share a prefix and database. Give each a unique `WP_REDIS_PREFIX`, then flush.

**Changes made in the database do not appear.** Cached options. Flush the object cache.

**Pages got slower after enabling it.** Usually latency: if each cache call crosses a slow network, hundreds of calls per page add up. Check that WordPress and Valkey are close together. Less often, Predis on a site making thousands of cache calls per page is slower than phpredis.

**`object-cache.php` keeps disappearing or is from another plugin.** Two cache plugins competing for the drop-in. Pick one and remove the other's object-cache feature.

**The site breaks entirely after a Valkey outage.** It should not - the plugin falls back to the database. If it does, set `WP_REDIS_DISABLED` to `true` to get the site up, and check the timeouts.

#### FAQ

##### Do I need an object cache if I already have a page cache?

If almost all of your traffic is anonymous and served from the page cache, it adds little. If you have logged-in users, a shop, a busy admin or many uncached API requests, the two solve different problems and are worth having together.

##### Is Redis Object Cache compatible with Valkey?

It speaks the Redis protocol through phpredis, Relay or Predis, and Valkey implements that protocol, so it connects with the same host, port and password settings. Point the constants at the Valkey server and enable it as usual.

##### Can several WordPress sites share one Valkey instance?

Yes, with a unique `WP_REDIS_PREFIX` per site, or separate logical databases with `WP_REDIS_DATABASE`. They will share memory, so a large site can push a small one's entries out. For important sites, separate instances keep them independent.

##### Will flushing the object cache lose data?

Not if plugins behave: everything in the object cache can be rebuilt from the database. Expect a slower minute while it warms up. The exception is a plugin that keeps essential data only in transients, which is a plugin bug.

##### Does the object cache help WooCommerce checkout?

Yes - cart, checkout and account pages cannot be page-cached, so they run PHP and query the database on every load. Caching products, options and session-related lookups reduces that work. It does not speed up payment gateway calls, which are external.

A Valkey server for your WordPress object cache, from 256 MB, with the host, port and a password generated for it and data saved to disk so the cache is warm after a restart. Put four constants in wp-config.php and enable the plugin.

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

---

### WordPress media offload to S3: plugins, URLs and trade-offs

Published 2026-10-08 by RE:NODE Team. https://www.renode.space/blog/wordpress-media-offload-to-s3

Offloading WordPress media means a plugin copies every file uploaded to the Media Library into an S3 bucket and rewrites the image URLs in your pages to point at the bucket instead of `wp-content/uploads`. It keeps the web server's disk small, lets several web servers share one media library, and survives rebuilding the server. It also adds a plugin you depend on forever, changes how backups work, and - the part most guides skip - only works if visitors' browsers can read the objects, which depends on what your storage allows. For a custom S3-compatible endpoint, the plugins that work cleanly are Human Made's S3 Uploads (configured in code) and Advanced Media Offloader (configured in settings, with a generic S3-compatible option); the popular WP Offload Media officially targets named providers and reaches custom endpoints only through filters. This post covers the setup, the URL and access questions, migrating an existing library, and an honest answer to whether you need any of it.

#### What offloading actually changes

Without offload, an upload goes to `wp-content/uploads/2026/10/photo.jpg` on the web server, WordPress generates the resized copies (`photo-300x200.jpg`, `photo-1024x683.jpg` and so on) next to it, and the post content contains a URL to the server itself.

With an offload plugin, the upload still lands on the web server first - WordPress needs the file locally to generate thumbnails and read metadata. The plugin then copies the original and every resized version to the bucket, records where each one went, and filters every place WordPress produces a media URL so the page points to storage. Some plugins delete the local copies afterwards to save disk; others keep them.

```diagram
title: An image upload with a media offload plugin
editor = Editor | uploads photo.jpg
wp = WordPress | resizes, then offloads
bucket = S3 bucket | originals + sizes
visitor = Visitor | loads the page
editor -> wp : upload
wp -> bucket : copy all sizes
visitor -> wp : HTML page
visitor -> bucket : image requests
```

Two consequences follow. Images are now fetched by visitors directly from the storage endpoint, so that endpoint must answer anonymous requests for those objects. And the files are now outside the web server, so a backup of the web server no longer contains your media.

#### When it is worth it, and when it is not

Offloading solves specific problems. If you do not have one of them, it adds moving parts for nothing.

Worth it when:

- **The media library is large** relative to the web plan's disk - a photography site or a shop with thousands of product images, where uploads are most of the disk usage.
- **More than one web server** serves the site, so every server needs the same files without syncing folders between them.
- **The web server is disposable** - you rebuild it from a repository and want nothing on it that cannot be recreated.
- **Downloads are large** - podcasts, video, software - and you want them out of the web server's bandwidth and disk.

Not worth it when:

- **It is a normal blog or company site** with a few gigabytes of images. Web hosting plans have enough disk for that, and the web server serves static files very efficiently.
- **You hope it will make the site faster on its own.** Moving images to a bucket in the same location as the web server does not shorten the distance to visitors. Speed comes from caching headers, image sizes and formats, and a CDN if your audience is far away. [WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching) covers what actually helps.
- **You rely on plugins that expect local files** - some image editors, PDF thumbnail generators, security scanners and backup plugins assume media is in `wp-content/uploads`.

There is also a middle path that people overlook: keep media where it is, and copy `wp-content/uploads` to a bucket every night as a backup. No plugin, no URL changes, and the bucket still saves you if the server is lost. The rclone approach in [game server backups to S3](https://www.renode.space/blog/game-server-backups-to-s3) works the same way for a WordPress uploads folder.

#### The public-access question

This is the part to settle before installing anything. An offloaded image is loaded by a visitor's browser with a plain `GET` and no credentials. For that to work, the storage must serve those objects anonymously. On AWS that is done with a bucket policy or object ACLs set to `public-read`. S3-compatible services vary in which of those mechanisms they support, and not every storage product offers public buckets at all.

RE:NODE's S3 storage, for example, is sold as private storage with an access key and secret generated for your server; public buckets and bucket policies are not part of what it promises. So test before you build on it. Upload a test object the way your plugin will, then fetch it without credentials:

```bash
$ aws s3 cp test.jpg s3://media/test.jpg --acl public-read --profile store
$ curl -I https://s3.example.com/media/test.jpg
```

A `200` with `Content-Type: image/jpeg` means anonymous reads work and a public offload setup will too. A `403 AccessDenied` means objects are private, and you have three honest options:

1. **Signed URLs.** Some plugins can serve media through presigned URLs that expire. S3 Uploads does this for attachments marked private. It works, but every page render produces different image URLs, which defeats page caching and browser caching - fine for a members-only area, poor for a public site. [Presigned URLs](https://www.renode.space/blog/s3-presigned-urls-for-uploads) explains the mechanics and their limits.
2. **Serve media through a server you control** that holds the keys and fetches from the bucket - a reverse proxy or a small app that signs requests. This is real work and adds a hop; it makes sense mainly when you need access control anyway.
3. **Use the bucket as the backup copy, not the serving copy.** Keep media on the web server and sync it to the bucket nightly. Simple, robust, and no URL rewriting.

For most WordPress sites on private storage, the third option is the right one.

#### The plugins and their custom-endpoint support

Three plugins come up in every discussion. They differ mostly in how they handle a non-AWS endpoint.

| Plugin | Configured in | Custom S3 endpoint | Path-style | Existing media |
| --- | --- | --- | --- | --- |
| S3 Uploads (Human Made) | `wp-config.php` and a filter | Yes, via `s3_uploads_s3_client_params` | Yes, `use_path_style_endpoint` | WP-CLI command |
| Advanced Media Offloader | Constants or settings page | Yes, generic S3-compatible option | Yes, a constant | Bulk tool and WP-CLI |
| WP Offload Media (Lite/Pro) | Settings page or `AS3CF_SETTINGS` | Not officially; via filters | Via the same filters | Pro bulk tool |

**S3 Uploads** is a developer's plugin: no settings screen, everything in code, installed from GitHub or Composer rather than the plugin directory. It is the most transparent about what it does, which makes it the easiest to debug. **Advanced Media Offloader** is in the WordPress plugin directory, has a generic S3-compatible provider option with an endpoint, a domain and a path-style switch, and can remove local copies after upload ("Full Cloud Migration") or keep them as a fallback. **WP Offload Media** is the best-known name; its Lite version officially supports Amazon S3, DigitalOcean Spaces and Google Cloud Storage, and the developer's own position on other S3-compatible services is that they probably work with filters but are not tested. If you already use it for AWS, it is fine; for a custom endpoint, one of the other two is less friction.

#### Setting up S3 Uploads with a custom endpoint

Install the plugin per its README (Composer: `composer require humanmade/s3-uploads`, or download a release into `wp-content/plugins`), then add the constants to `wp-config.php`, above the "That's all, stop editing" line:

wp-config.php:

```php
define( 'S3_UPLOADS_BUCKET', 'media/wp' );          // bucket, optional prefix
define( 'S3_UPLOADS_REGION', 'us-east-1' );         // any value the store accepts
define( 'S3_UPLOADS_KEY', getenv( 'S3_KEY' ) );
define( 'S3_UPLOADS_SECRET', getenv( 'S3_SECRET' ) );
define( 'S3_UPLOADS_BUCKET_URL', 'https://s3.example.com/media/wp' );
define( 'S3_UPLOADS_HTTP_CACHE_CONTROL', 'public, max-age=31536000' );
```

The endpoint and path-style switch go in a small must-use plugin, because they are SDK client options rather than plugin settings:

wp-content/mu-plugins/s3-endpoint.php:

```php
<?php
add_filter( 's3_uploads_s3_client_params', function ( $params ) {
    $params['endpoint'] = 'https://s3.example.com';
    $params['use_path_style_endpoint'] = true;
    $params['request_checksum_calculation'] = 'when_required';
    $params['response_checksum_validation'] = 'when_required';
    return $params;
} );
```

The two checksum lines are for recent versions of the AWS SDK for PHP (3.337 and later), which add integrity checksums that some S3-compatible servers reject; the plugin's own README recommends them for third-party endpoints. `S3_UPLOADS_BUCKET_URL` sets the base URL written into pages - with path-style that is the endpoint plus the bucket and prefix. Keep the key and secret out of the file itself if you can, as the `getenv()` calls above do; [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers why.

S3 Uploads sets `public-read` on objects by default (`S3_UPLOADS_OBJECT_ACL`). On storage that does not honour ACLs, that setting has no effect, which brings you back to the public-access test above.

Verify the setup with WP-CLI before relying on it: `wp s3-uploads verify` checks that the plugin can write to and delete from the bucket.

#### Migrating an existing library

New uploads are offloaded automatically. Everything uploaded before the plugin was enabled is not, and those posts still point at `wp-content/uploads`. Migration has two halves: copy the files, and change the URLs.

1. **Back up first.** Database and `wp-content/uploads`. The URL change is a search-and-replace across your content.
2. **Copy the files.** With S3 Uploads: `wp s3-uploads upload-directory wp-content/uploads uploads`. With Advanced Media Offloader: its bulk tool, or `wp advmo offload` for large libraries.
3. **Rewrite URLs in content.** Offload plugins filter URLs that WordPress generates, but URLs pasted into post content are plain text. A WP-CLI search-replace handles them: `wp search-replace 'https://example.com/wp-content/uploads' 'https://s3.example.com/media/wp/uploads' --all-tables --dry-run`, then again without `--dry-run`. Check that the target path matches where your plugin actually stores files.
4. **Check pages.** Open a few old posts and a product gallery, and look for images still loading from the old path in the browser's network panel.
5. **Only then remove local copies**, if you want the disk back. Keep them until you are sure.

Reversing a migration is the same process the other way round, and it is the strongest argument for keeping local copies: a site whose only media copy is in the bucket is locked to that plugin and that storage.

#### Backups after offloading

This is the change people discover during a restore. Your web server's backups - panel backups, a backup plugin, a `tar` of the site - now contain the database and the code, but not the media. The bucket is the only home of any file whose local copy was removed.

On RE:NODE, the S3 storage keeps one copy on NVMe in one location, without replication, and storage plans carry no panel backup slots of their own. That is fine for a serving copy, as long as something else holds a second one. Two ways to keep it:

- **Keep local copies** on the web server, so the existing web-server backups still include media.
- **Sync the bucket elsewhere**, for example `rclone sync store:media/wp /backups/wp-media` on a machine you control, or to a second provider, on a schedule.

Also back up the plugin's settings and, for plugins that track offloaded items in their own database table, the database - restoring the files without that table leaves the plugin not knowing where anything is. [Migrate WordPress to a new host](https://www.renode.space/blog/migrate-wordpress-to-a-new-host) is worth reading with this in mind; an offloaded site has one more piece to move.

#### Common problems

**Images 403 after offload.** Objects are private and the storage does not serve anonymous reads. See the public-access section; either use signed URLs or keep media local.

**New uploads fail with a signature or checksum error.** The SDK's newer default checksums; add the two `when_required` options.

**Some images still load from `wp-content/uploads`.** Hard-coded URLs in content or in a page builder's data. Search-replace, and for page builders that store serialised data, use `wp search-replace`, which handles serialisation correctly - a plain SQL `REPLACE()` breaks it.

**Thumbnails missing in the bucket.** Sizes generated by a theme or plugin after the original upload are not always offloaded. Regenerate thumbnails, then re-run the plugin's offload for those items.

**The site got slower.** Image requests now go to a second hostname, with its own connection and TLS handshake. Long `Cache-Control` headers help repeat visits; [HTTP caching headers](https://www.renode.space/blog/http-caching-headers-explained) covers the values. For distant visitors, a CDN in front of the media hostname is the real fix.

#### FAQ

##### Does offloading media make WordPress faster?

Not by itself. The web server serves static images efficiently already, and a bucket in the same location is the same distance from visitors. Offloading saves disk and helps multi-server setups; speed comes from caching, image optimisation and a CDN.

##### Which plugin works with an S3-compatible endpoint?

S3 Uploads, through a filter that sets the endpoint and path-style, and Advanced Media Offloader, through its generic S3-compatible option. WP Offload Media can be made to work with filters but does not officially support custom endpoints.

##### Do I have to delete local copies?

No, and keeping them is safer. The local copies keep your web-server backups complete and make it possible to switch the plugin off without losing images. Remove them only if disk space is the reason you offloaded.

##### Can I offload only some files?

Most plugins offload everything in the Media Library. If you only want large downloads in the bucket, upload those directly with an S3 client and link them, and leave the library alone.

##### What happens if I deactivate the plugin?

URLs revert to `wp-content/uploads`. If the local files are still there, the site keeps working; if they were deleted, images break until you copy them back from the bucket and rewrite any URLs that were changed in content.

RE:NODE's S3 storage works with WordPress offload plugins that accept a custom endpoint: path-style addressing, any region name, an access key and secret key generated for your server and a first bucket created, with HTTPS through the plan's proxy slot. It is one copy in one location, so keep a second copy of your media somewhere else.

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

---

### IP leaks and doxxing for server owners

Published 2026-10-07 by RE:NODE Team. https://www.renode.space/blog/ip-leaks-and-doxxing-for-server-owners

As a game server owner, the address of your server is supposed to be public; the things to protect are your home IP address, your real name, where you live, and the accounts that lead back to them. Running the server in a datacentre rather than at home removes the biggest leak at once, because the only address players ever see belongs to the host. What remains is a handful of quieter leaks that catch people for years afterwards: a domain that once pointed at your house and is still in DNS history, your legal name on a payment page, the same username on your server and on your personal accounts, a screenshot with your Windows user folder in it, and links you click that log your address. Close those and a bad-tempered banned player has nothing to work with.

This is a post about your own exposure as the person running things. Protecting your players' data is a related but separate subject, covered in [game server privacy and player data](https://www.renode.space/blog/game-server-privacy-and-player-data).

#### What is actually at risk

Not every piece of information is equally dangerous. Rank them before you worry about all of them at once.

| What | How it usually leaks | What someone can do with it |
| --- | --- | --- |
| Home IP address | Hosting at home, old DNS records, clicked links | Attack your home connection, approximate location |
| Real name | Payments, domain records, reused accounts, Git commits | Find your social media, workplace, family |
| Home address | Domain records, combining the above | Harassment, threats, worse |
| Personal accounts | Reused usernames and email addresses | Takeover attempts, more information |
| Your server's address | Nowhere - it is public by design | Attack the server, which the host absorbs |

The bottom line is the reassuring one. A server's IP address in a datacentre tells an attacker nothing about you. Attacks against it land on infrastructure built to take them, not on your home router - [DDoS attacks on game servers explained](https://www.renode.space/blog/ddos-attacks-on-game-servers-explained) covers what that looks like and what filtering can and cannot do.

Doxxing is rarely one big leak. It is usually several small ones combined: a username leads to an old forum profile, which has an email address, which appears in a domain registration, which has a name, which leads to a social media account with a photo of a recognisable street. Each step is innocent on its own. Your goal is to break the chain at as many points as you can.

#### Hosting at home is the biggest single leak

A server hosted on your home connection publishes your home IP address to every player who joins, and to every server list, query tool and scanner on the internet. There is no configuration that hides it, because players have to connect to something.

What that exposes:

- **Your approximate location.** IP geolocation is often accurate to a city, sometimes to a district.
- **Your home connection to attacks.** A banned player with a cheap "booter" service can take your whole household offline, including your work calls and everyone else's streaming. Residential connections have no filtering in front of them.
- **Your ISP.** Which, combined with social engineering, is occasionally used to try to get more information about the account holder.

The options, roughly in order of how well they protect you:

1. **Rent the server.** The address players see belongs to the host, in a datacentre. This is the simplest fix and the reason most communities stop hosting at home after their first attack. [Hosting at home vs renting](https://www.renode.space/blog/self-hosting-a-game-server-at-home-vs-renting) covers the costs on both sides honestly.
2. **Put a relay in front of the home server.** A tunnel or a small rented server that forwards traffic, so players see the relay's address. It costs some latency and some complexity - [tunnels and proxies for game servers](https://www.renode.space/blog/tunnels-and-proxies-for-game-servers) covers the trade-offs.
3. **Keep a home server private.** A server for friends only, joined over a private network such as a VPN mesh, never listed publicly and never given to strangers. Fine for a small trusted group.

If you have hosted at home before and moved, remember that your old address may already be recorded: in server list histories, in screenshots, in old messages. Restarting your router may give you a new address on many residential connections, and your ISP can sometimes issue one on request.

#### Other ways your home IP leaks

Even with the server in a datacentre, your home address can leak from your own activity.

- **Links you click.** Any website you visit sees your IP address. "IP logger" services exist specifically to wrap a normal-looking link and record who opened it. A banned player sending you "proof" as a link, or a "ban appeal" on an odd-looking site, may be doing exactly this. Open links from people you are in dispute with in a browser behind a VPN, or not at all.
- **Peer-to-peer games and listen servers.** When you host a match from your own game client, or join a game that connects players directly, other players can see your address. Using a dedicated server for your community games avoids this for those games.
- **Self-hosted voice servers.** A TeamSpeak or Mumble server run at home is the same exposure as a game server at home. Discord voice, by contrast, connects you to Discord's servers rather than to other users, so it does not reveal your address to the people you talk to. [Voice servers for communities](https://www.renode.space/blog/voice-servers-teamspeak-mumble-for-communities) covers hosting voice properly.
- **Admin tools and RCON from home.** Your home IP appears in the server's own logs whenever you connect to RCON or join as a player. That is fine as long as those logs stay private - which is a reason not to give log access to everyone on the staff team.
- **Email.** Some email providers and clients include the sender's IP address in message headers. Large webmail services generally do not, but a desktop mail client sending through your own server might. Send community email from a webmail account.

#### Domains, DNS history and the records that outlive you

A domain makes a server easy to find, and it is also one of the most common sources of doxxing information, because the internet remembers DNS.

**DNS history.** Several services record what every domain has resolved to over time. If `play.example.com` pointed at your home address for a year before you moved to a rented server, that address is still visible in those histories, sometimes years later. You cannot delete it. What you can do is make sure the address no longer leads anywhere useful - which, if you have since moved home or your ISP has changed your address, it may not.

**Forgotten subdomains and records.** A `home.example.com` record pointing at your NAS, an `MX` record pointing at a mail server in your house, a `TXT` record mentioning a personal email. Audit the whole zone, not just the record for the game. [DNS records explained](https://www.renode.space/blog/dns-records-explained) covers what each type exposes.

**Registration details.** Domain registrations used to publish the owner's name, address, email and phone number in WHOIS. Since the GDPR, most registrars redact personal details for individuals by default, and most offer a privacy or proxy service for free. Check your own domain's public record with any WHOIS lookup and make sure it shows the registrar's privacy details, not yours. Some country-code domains have their own rules, so check before you register one.

**Cloudflare and other proxies.** Putting a domain behind Cloudflare hides the origin address of a website, but it does not proxy game traffic on the normal plans, so the game server's address is still visible to anyone who connects. That is fine for a server in a datacentre; it is not a way to hide a home server. [Cloudflare for websites and game servers](https://www.renode.space/blog/cloudflare-for-websites-and-game-servers) explains why.

```text
# What a careful zone for a community looks like
play.example.com.     A      203.0.113.10    ; game server at the host
example.com.          A      198.51.100.20   ; website at the host
_minecraft._tcp.play  SRV    0 5 25565 play.example.com.
# No records pointing at a home connection, no personal email in TXT
```

#### Your name, your accounts and the things you post

The rest of the chain is identity rather than addresses.

- **Use a separate identity for the community.** A username you do not use for your personal accounts, and an email address created for the community. If your community name and your gaming handle are the same as your old personal accounts, someone will find the connection in minutes.
- **Payments show names.** Donation pages and payment providers can show your legal name to the person paying, depending on the service and account type. Store platforms that act as the seller put their own name in front of yours. Check what a payer sees by making a small test payment to yourself.
- **Git commits carry an email address.** If you publish plugins or configs on GitHub, configure the private no-reply address GitHub provides, or every commit shows your personal email. Old commits keep whatever was set at the time.
- **Screenshots show more than you think.** A Windows user folder path such as `C:\Users\JohnSmith\`, a taskbar with personal notifications, a browser with your real-name account logged in, a file manager showing your documents. Crop tightly, or take screenshots from a separate user account.
- **Photos can carry location data.** Phone photos often contain GPS coordinates in their metadata. Strip metadata before posting images outside platforms you know remove it, or turn off location tagging in the camera.
- **Streams and voice.** Real names said out loud by family members, a window visible behind you, a parcel label on the desk. Streaming the server is a good way to grow it; just look at what is in the frame.

> [!WARNING]
> The person most likely to dox you is not a skilled stranger. It is a banned player or a former staff member who already knows your username, has been in your voice channel and has seen your screenshots. Assume that anything you have shared with your community can be collected by the angriest person in it.

#### Staff, support and social engineering

People are a leak as well as files.

- **Staff do not need your personal details.** Share what they need to run the server and nothing else. If someone needs to reach you urgently, the community email or Discord is enough.
- **Staff access to logs is access to players' addresses** - and to yours, if you join the server or connect to RCON. Limit log access to the people who need it; on a panel with subusers, moderators can have console access without file access.
- **Attackers impersonate you.** Someone may contact your host, your domain registrar or your Discord's members claiming to be you. Two-factor on every account that matters - panel, registrar, email, Discord - stops most of it. [Two-factor on your panel account](https://www.renode.space/blog/two-factor-on-your-panel-account) covers the panel; do the same for the email address everything else resets to.
- **Keep personal information out of support tickets** beyond what is needed to verify the account. A ticket system is not where your home address belongs.

#### If it happens anyway

If someone publishes your personal details or threatens you:

1. **Do not engage.** Responding publicly gives them attention and often more information.
2. **Document everything.** Screenshots with dates, links, usernames, message IDs. Save copies off the platform.
3. **Report to the platform.** Discord, social networks and forums prohibit posting personal information and act on reports, especially with evidence.
4. **Contact the police** if there are threats, or if you are worried about someone acting on your address. Some places offer a way to flag an address as at risk of hoax emergency calls; ask.
5. **Change what can be changed.** A new home IP address (from your ISP or a router restart on a dynamic connection), new passwords, two-factor everywhere, privacy settings on social accounts.
6. **Tell your staff** what happened so they do not accidentally confirm anything when the attacker contacts them.

It is also worth telling your community, briefly and calmly, that doxxing gets an immediate permanent ban and a report to the platform. Making it clear the behaviour is taken seriously discourages the next person.

#### Protect your players the same way

Everything above applies to your players too, and you are the one holding their addresses.

- **Do not post console output publicly.** Minecraft join lines include IP addresses, and a Discord console channel that mirrors the console shows them to everyone who can read the channel. Keep console bridges in staff-only channels and filter join lines if you can.
- **Do not put addresses in public ban lists**, web maps or statistics pages.
- **Treat leaking a player's address as one of your most serious rules**, for staff as much as players.

#### FAQ

##### Is it dangerous that players can see my server's IP address?

Not if the server is hosted in a datacentre. That address belongs to the host, and attacks against it hit infrastructure built for them. It is only a personal risk when the server runs on your home connection.

##### Does a domain name hide my server's IP?

No. A domain is just a name that resolves to the address, and anyone can look it up. It makes the server easier to move and remember, but it does not hide anything.

##### Can someone find my home address from my IP?

Usually only a city or region from geolocation alone. The danger comes from combining an IP address with other information, or from social engineering an ISP, which is why the other leaks in this post matter more.

##### Should I use a VPN as a server owner?

It helps for one specific job: opening links and websites from people you do not trust, so they do not see your home address. It does nothing for a server hosted at home, and it is not a substitute for keeping your identity separate.

##### I hosted at home before. Is my old address still out there?

Probably, in DNS history and old server listings. If your ISP gives you a dynamic address, it may have changed since. If not, many ISPs will issue a new address on request, which makes the old records useless.

A game server on RE:NODE runs on hardware in Germany, so the address your players see belongs to the host, never to your home, and upstream filtering drops obvious volumetric floods before they reach the server. Two-factor on the panel account and subusers without file access keep logs and your account out of reach of people who should not have them.

[See game server plans](https://www.renode.space/game-servers)

---

### Cloudflare for websites and game servers

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/cloudflare-for-websites-and-game-servers

Cloudflare's proxy - the orange cloud next to a DNS record - carries HTTP and HTTPS on a specific list of ports, and nothing else. Put it in front of a website and you get caching, a free certificate, a web firewall and a hidden origin address. Point it at a Minecraft server, an Ark server or anything else speaking its own protocol on its own port and the connection simply fails, because there is no HTTP request for Cloudflare to read. That one sentence resolves about eighty per cent of the questions people have about Cloudflare, and the rest of this post is the detail: which ports, which settings, which errors, and what the orange cloud never protects no matter how it is configured.

#### What the orange cloud actually changes

Cloudflare is your authoritative DNS host, and each record has a toggle. Grey cloud - "DNS only" - means Cloudflare answers the query with your real address and steps out of the way. Orange cloud means it answers with one of its own anycast addresses, and traffic to that address is handled by the nearest Cloudflare data centre, which then makes its own connection to your server.

Everything else follows from that substitution:

- `dig www.example.com` returns a Cloudflare address such as `104.21.x.x` or `172.67.x.x`, never yours.
- The TTL field is locked to Auto, because Cloudflare controls it.
- Your origin only ever sees connections from Cloudflare's ranges, so the visitor's address must come from a header.
- Anything that inspects or rewrites the request - caching, the firewall, redirects, compression - can only happen on records that are proxied.

```diagram
title: Two records, two paths to the same host
visitor = Visitor | browser
player = Player | game client
cf = Cloudflare edge | proxied, HTTP only
web = Web server | port 443
game = Game server | own port and protocol
visitor -> cf : www.example.com
cf -> web : HTTP request
player -> game : play.example.com
game --> web : same machine
```

A zone almost always ends up mixed: `www` and the apex orange, `play` and `mail` grey. That is not a compromise, it is the correct configuration. [DNS records explained](https://www.renode.space/blog/dns-records-explained) covers the record types themselves if the A/CNAME/SRV distinction is new.

#### The ports it will proxy, and everything else

A proxied record only accepts connections on Cloudflare's supported ports. Anything else is refused or times out, which is why "I enabled the orange cloud and my site on port 3000 stopped working" is a weekly question.

| Protocol | Ports carried by the proxy |
| --- | --- |
| HTTP | 80, 8080, 8880, 2052, 2082, 2086, 2095 |
| HTTPS | 443, 2053, 2083, 2087, 2096, 8443 |

Note what is not there: 25 (SMTP), 3306, 5432, 22, 25565, 27015, and every UDP port in existence. The proxy is an HTTP reverse proxy. It terminates TLS, parses a request, applies rules, and forwards. A protocol it cannot parse is a protocol it cannot proxy.

Cloudflare does sell a product for raw TCP - Spectrum - which is a paid add-on: arbitrary TCP applications on the Business and Enterprise plans, with Minecraft support available lower down the range. Prices and plan boundaries move, so check the current plan page before designing around it, and price it honestly against simply having a decent host.

#### Game servers and the orange cloud

This is the section people arrive for, so here is the whole thing plainly.

**Game traffic cannot go through the free Cloudflare proxy.** Most games use UDP, which the HTTP proxy does not carry at all. The games that use TCP - Minecraft Java on 25565, for instance - use a port that is not on the supported list. Turning the orange cloud on for a game record produces one of two results: the client cannot connect, or it connects to Cloudflare's web edge and gets an HTTP error where it expected a handshake.

The record for a game server stays grey. That is normal and it is what every host expects:

```text
Type   Name    Content          Proxy
A      play    203.0.113.10     DNS only
SRV    _minecraft._tcp.play     DNS only
```

SRV records cannot be proxied at all - Cloudflare will not offer you the toggle - and if the hostname an SRV record points at is itself orange-clouded, the lookup resolves to Cloudflare and the connection fails. Both the SRV record and the A record it targets must be DNS only. [SRV records for Minecraft](https://www.renode.space/blog/srv-records-for-minecraft) covers the format, and [connecting a domain to a game server](https://www.renode.space/blog/connecting-a-domain-to-a-game-server) covers the ordinary A record case.

Two consequences follow, and the second one is the uncomfortable part.

First, **Cloudflare does not hide a game server's address**. The grey-clouded record publishes it, because it must - the client has to reach your machine directly. Anyone who can read DNS can read that address, including someone planning to attack it.

Second, **nothing about the orange cloud on your website protects the game server sharing that address**. If your web records are proxied and your game record is not, you have hidden precisely nothing: the A record for `play` gives away the same IP the website is on. This is the most common way an origin address leaks.

What actually protects a game server is filtering upstream of it, at the network your host runs. On RE:NODE the wording is deliberately narrow: upstream filtering that drops obvious volumetric floods, plus reflection and malformed traffic. Attacks that look like real players are not filtered, because at that point they are indistinguishable from real players without knowing the game. A host that promises no downtime under any attack is selling you a sentence, not a service - [what we do about attacks](https://www.renode.space/blog/what-we-do-about-attacks) is the longer version, and [DDoS attacks on game servers explained](https://www.renode.space/blog/ddos-attacks-on-game-servers-explained) covers the attack types themselves.

> [!WARNING]
> Putting a game server behind a "DDoS-protected proxy" you found for five dollars usually means routing every packet through a machine with worse peering than your host, adding 40 ms in each direction. Measure before and after. Latency is the thing your players will actually notice.

#### SSL modes, certificates and redirect loops

Under SSL/TLS there is a mode setting that decides how Cloudflare talks to your origin, and picking the wrong one causes two very common failures.

| Mode | Cloudflare to origin | Use it |
| --- | --- | --- |
| Off | Plain HTTP, no HTTPS at the edge | Never |
| Flexible | Plain HTTP | Never, if you can avoid it |
| Full | HTTPS, any certificate accepted | Acceptable stop-gap |
| Full (strict) | HTTPS, certificate validated | This one |

**Flexible** is the trap. Visitors see HTTPS, but the connection from Cloudflare to your server is plain HTTP over the public internet, so the padlock is telling the visitor something untrue. It also produces the classic `ERR_TOO_MANY_REDIRECTS`: Cloudflare sends an HTTP request, your server redirects HTTP to HTTPS, Cloudflare follows that back to itself, forever. If a site loops immediately after enabling Cloudflare, this is why - switch to Full (strict).

Full (strict) needs a valid certificate on your origin. Either the one your host already issues, or a free Cloudflare Origin CA certificate, which is trusted by Cloudflare only and can be issued for up to fifteen years. Both work; the host-issued one is less to remember.

The interaction that bites on a managed host is certificate renewal. An HTTP-01 validation has to reach your origin on port 80 at `/.well-known/acme-challenge/`, and a firewall rule, a redirect or Flexible mode can all stop it silently. On RE:NODE, app and web plans include a proxy slot that issues and renews the certificate automatically inside a 21-day window, which means a renewal blocked by something you configured in Cloudflare does not fail today - it fails two months later when the existing certificate expires. If you put Cloudflare in front of a host-managed certificate, grey-cloud the record long enough for a renewal to complete, or switch that host to DNS validation if it offers it. [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained) covers how the validation works.

Two settings worth turning on once Full (strict) is working: Always Use HTTPS, which does the HTTP-to-HTTPS redirect at the edge so your server never sees a plain request, and Automatic HTTPS Rewrites, which fixes mixed-content references in your own HTML.

#### Caching: what is cached by default and what is not

By default Cloudflare caches static assets by file extension - images, CSS, JavaScript, fonts - and does not cache HTML. For a WordPress site or any dynamic application, that means the expensive part of every page view still reaches your server. People are consistently surprised by this, having assumed the orange cloud made the site fast.

To cache HTML you create a Cache Rule that sets "Eligible for cache" (the replacement for the old Page Rules "Cache Everything"), and then you must handle the logged-in case. Serve one visitor's dashboard from the edge to everyone else and you have created a data breach with a checkbox. The rules that keep this safe:

- Bypass the cache when a session cookie is present - `wordpress_logged_in_*`, `PHPSESSID`, your framework's cookie name.
- Bypass the cache entirely for admin paths and any API that returns per-user data.
- Set a short edge TTL to start with, and raise it once you trust it.

The cleaner approach is to let your own headers drive it. `Cache-Control: public, max-age=31536000, immutable` on hashed asset filenames, and `Cache-Control: private, no-store` on anything personal, is a policy that works on every CDN rather than one vendor's dashboard. [HTTP caching headers explained](https://www.renode.space/blog/http-caching-headers-explained) goes through the header set properly.

Two operational notes. Development Mode bypasses the cache for three hours and turns itself off, which is what you want while editing a theme. And purge by URL rather than purging everything - a full purge on a busy site sends every request to your origin at once, which is a self-inflicted load spike.

#### Security settings worth having, and two that break things

The genuinely useful ones, in order of value on a small site:

- **The managed WAF rules.** Free plans get a subset, and it stops a large amount of automated nonsense before it reaches PHP.
- **Rate limiting.** Free plans include one rule. Spend it on the login endpoint - that is where the credential stuffing goes.
- **Country or ASN blocking**, if your audience is genuinely regional. Blunt, but effective against a specific problem.
- **Blocking by path.** `/wp-login.php`, `/xmlrpc.php` and `/.env` requests from everywhere except your own address cost nothing to block and are most of an attacker's opening move.

The two that break things, and are enabled by people who do not connect the outage to the setting:

**Bot Fight Mode** challenges anything it decides is not a browser. That includes your payment provider's webhooks, your GitHub deploy hook, your uptime monitor, your mobile app and your RSS readers. The symptom is a `403` that never appears in your own logs, because your server was never asked. If you run any machine-to-machine traffic, leave it off or exclude those paths.

**Under Attack mode** shows a five-second interstitial to every visitor before letting them through. It works against a flood, and it breaks every API client and every WebSocket handshake for as long as it is on. It is an emergency switch, not a configuration - turn it off when the emergency ends. WebSockets themselves are proxied fine on all plans; it is the challenge that kills them, and [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) covers the other idle-timeout behaviour to expect.

#### Seeing the real visitor address

Once traffic is proxied, every connection arrives from Cloudflare. Your access log fills with their ranges, rate limiting by IP throttles everyone at once, and any geolocation you do is measuring a data centre.

The real address arrives in `CF-Connecting-IP`, and is also appended to `X-Forwarded-For`. In nginx, restore it so that logs, rate limits and application code all see the truth:

```nginx
# Repeat set_real_ip_from for every range published at cloudflare.com/ips
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;
```

Never trust that header from a source that is not Cloudflare. It is a plain request header, so anyone connecting to your origin directly can set it to whatever they like - which is exactly why the `set_real_ip_from` list matters. On a managed host you usually cannot edit nginx, but the same logic applies to the header your platform gives you: on RE:NODE the proxy slot passes the original client address in `X-Forwarded-For`, and if Cloudflare is also in the chain, that header becomes a list where the leftmost entry is the visitor and each hop appended the one before it.

The other half of this is a firewall that only accepts connections from Cloudflare on 80 and 443, so nobody can skip the proxy by connecting to your address directly. On your own VDS that is a handful of `ufw` rules plus Authenticated Origin Pulls. On a shared, container-based host it is generally not something you control, which is a real limitation worth knowing rather than assuming.

#### Reading Cloudflare's error pages

Cloudflare's five-hundred errors are specific, and each one points at a different half of the system. This table saves a lot of guessing:

| Error | Meaning | Usually |
| --- | --- | --- |
| 520 | Origin returned something unintelligible | An application crash, or an enormous header |
| 521 | Origin refused the connection | Your app is down, or the firewall blocks Cloudflare |
| 522 | Connection timed out | Origin overloaded, or a firewall dropping silently |
| 523 | Origin unreachable | A record points at the wrong address |
| 524 | Origin took too long to answer | A request exceeding the roughly 100-second edge timeout |
| 525 / 526 | TLS handshake failed or certificate invalid | Full (strict) against a self-signed or expired certificate |
| 1020 | Access denied | One of your own firewall rules matched |

The distinction to internalise is between 521 and 522. A refused connection means something answered with "no" - the port is closed or filtered with a reject. A timeout means nothing answered at all, which on a busy server means the request queue is full, and on a firewalled one means packets are being dropped. Checking your own logs answers it immediately: if there is no entry for the request, it never arrived.

A 524 needs mentioning separately because it is not really an error. A request that takes longer than about 100 seconds is cut off at the edge, and on the lower plans that limit is not configurable. Work that takes minutes should not happen inside a request at all - it should be queued and polled. [Background jobs on a small server](https://www.renode.space/blog/background-jobs-on-a-small-server) covers how to move it.

#### What Cloudflare will not do for you

A short, honest list, because the marketing does not volunteer it.

- **It does not host your email.** MX records must be DNS only, and if your mail server shares an address with your web server, that MX record publishes your origin. Cloudflare's Email Routing forwards mail; it is not a mailbox. RE:NODE does not host email either.
- **It does not hide an address that has already leaked.** Old DNS history, a grey-clouded subdomain, a certificate transparency log entry, or an application that makes outbound requests can all give it away. Changing the origin address after enabling the proxy is the only real fix.
- **It does not make a slow application fast.** Uncached HTML still comes from your server, at your server's speed. If the database query takes 800 ms, it takes 800 ms with an orange cloud in front of it.
- **It does not cover non-HTTP services.** Databases, SSH, game ports and mail all bypass it completely, and each one is a direct route to your machine that your firewall has to deal with.
- **It does not accept unlimited uploads.** The free and Pro plans cap a request body at 100 MB. Large file uploads need to go somewhere else, or through a grey-clouded hostname.
- **It does not replace backups, updates or a firewall.** It filters traffic before it arrives; everything after that is still yours.

Used for what it is - an HTTP edge in front of a website - it is excellent, free, and takes twenty minutes to set up. Used as a shield for a game server, it is a misunderstanding that costs an afternoon.

#### FAQ

##### Can I use Cloudflare's proxy for a Minecraft server?

Not on the free plan. Minecraft Java uses TCP 25565, which is not one of the proxied ports, and the proxy only understands HTTP. Leave the record DNS only, or look at Spectrum, which is a paid add-on with Minecraft support.

##### Does the orange cloud hide my game server's IP address?

No. The record the game client uses must be DNS only, so it publishes your real address by definition. If your website is proxied but shares that address, the game record leaks it for both.

##### Why did my site start redirecting in a loop after enabling Cloudflare?

The SSL mode is Flexible, so Cloudflare asks your origin over plain HTTP, your origin redirects to HTTPS, and Cloudflare follows the redirect back to itself. Switch to Full (strict) with a valid certificate on the origin.

##### Are WebSockets supported?

Yes, on every plan, with no setting to enable. Idle connections are closed after roughly 100 seconds, so send a heartbeat every 30, and keep bot challenges away from the WebSocket path.

##### Why does my server see Cloudflare's IP instead of the visitor's?

Because Cloudflare makes the connection, not the visitor. Read `CF-Connecting-IP`, or configure your proxy to restore the real address from it, and only trust that header when the connection comes from a published Cloudflare range.

##### Does Cloudflare replace what my host does about attacks?

No, and it cannot, because it only sees the traffic that goes through the proxy. Filtering upstream of the server still matters for everything else - the game ports, the database port, and anything on a DNS-only record.

Game servers on RE:NODE come with their port allocations stated on the plan and more available on the Network tab, so you can point a plain A record at the address and have players connect directly. Upstream filtering drops obvious volumetric floods, reflection and malformed traffic - the traffic Cloudflare's HTTP proxy was never going to see.

[See game server plans](https://www.renode.space/game-servers)

---

### Deploy an Express API to production

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/deploy-express-api-to-production

An Express app that works on your machine is about fifteen lines away from one that works on a server, and every one of those lines is something the tutorials skip because it does not matter locally. The app has to bind to the port the host gave it, on every interface. It has to know it is behind a proxy, or `req.ip` is the proxy's address and your rate limiter counts everyone as one person. It needs an error handler that catches rejected promises, a shutdown that drains in-flight requests, and keep-alive timings that do not race the thing in front of it.

This is that list, in the order it bites. It applies to any Node HTTP server - Fastify, Koa, a bare `http.createServer` - but the specifics here are Express, because that is what most people are deploying.

#### Listen on the right port, and the right interface

Two lines, both of which are wrong in the default tutorial:

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

app.listen(port, () => {
  console.log(`listening on ${port}`);
});
```

The port comes from the environment. A hosting platform allocates one and tells you about it through a variable; hard-coding `3000` means the app either fails to bind or binds to a port nothing routes to. On RE:NODE, app plans include one port allocation, shown on the Network tab and available to the process as an environment variable set on the Startup tab.

The interface is the subtler half. `app.listen(port)` with no host binds to all interfaces, which is what you want. `app.listen(port, "127.0.0.1")` binds to loopback inside the container, and then nothing outside it - including the reverse proxy in front of it - can connect. The symptom is an app that starts perfectly, logs "listening", and answers nothing: connection refused from the proxy, a 502 to the browser, and not one line in your own log. If you must be explicit, write `"0.0.0.0"`.

The same applies to anything else that listens. A metrics endpoint, a debug server, a WebSocket upgrade path: all of them live behind the same single allocation, so mount them on the same server rather than opening a second port you do not have.

#### Behind the proxy: trust proxy and the real client IP

In production your app is not talking to the browser. It is talking to a reverse proxy that terminated TLS, and the original request details survive only as headers.

```diagram
title: What sits between a client and your route handler
client = Client | HTTPS
proxy = Proxy slot | TLS, X-Forwarded-For
express = Express | middleware stack
route = Route handler | your code
db = Database slot | queries
client -> proxy : 443
proxy -> express : allocated port
express -> route : after middleware
route --> db
```

Without configuration, Express reports the proxy's address as `req.ip`, `http` as `req.protocol`, and `false` for `req.secure`. Everything that depends on those is then quietly wrong: rate limits keyed by IP become a single global bucket, audit logs record one address for every user, and a redirect to the canonical host sends people to `http://`.

```js
app.set("trust proxy", 1);
```

The `1` means "trust one hop". It tells Express to take the last entry in `X-Forwarded-For` as the client, because exactly one proxy added it. Count your hops: one proxy is `1`, a CDN in front of a proxy is `2`. You can also pass a subnet, a list, or one of the named presets such as `loopback`.

> [!WARNING]
> Do not set `trust proxy` to `true`. That trusts the whole header, and `X-Forwarded-For` is trivially forged by the client, so anyone can hand you a fresh fake IP on every request and walk straight through a rate limiter or an IP allow-list. express-rate-limit warns about exactly this configuration, and recent versions refuse to start with it. Trust a number of hops, not "whatever is in the header".

With the setting correct, `req.ip` is the real client address, `req.protocol` is `https`, and `req.secure` is true. On RE:NODE the proxy slot forwards the client address in `X-Forwarded-For` and handles the certificate itself - point an A record at the address shown and it is issued and renewed automatically inside a 21-day window. The mechanics are in [what a reverse proxy actually does](https://www.renode.space/blog/what-a-reverse-proxy-does) and [pointing a domain at your server](https://www.renode.space/blog/your-domain-and-its-certificate).

If your API serves WebSockets, the proxy has to be willing to pass the upgrade through, and the idle timeouts matter more than they do for plain requests. [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) is the specific case.

#### Middleware, in the order it has to be in

Express runs middleware in registration order, and several production problems are ordering problems rather than configuration ones.

```js
import express from "express";
import helmet from "helmet";
import compression from "compression";
import cors from "cors";
import pinoHttp from "pino-http";

const app = express();

app.set("trust proxy", 1);
app.use(pinoHttp());                       // log everything, including failures
app.use(helmet());                         // headers before any response body
app.use(compression());                    // before the routes that produce bodies
app.use(cors({ origin: ["https://app.example.com"], credentials: true }));
app.use(express.json({ limit: "100kb" }));
```

What each one is actually doing:

- **helmet** sets a group of response headers and removes `X-Powered-By`. For a JSON API the important ones are `X-Content-Type-Options: nosniff`, `X-Frame-Options`, a referrer policy and HSTS. Its default Content Security Policy is aimed at HTML, so if you serve documentation or an admin page from the same app, configure the policy rather than deleting helmet. Check the HSTS `max-age` in the version you installed before quoting a number to anyone.
- **compression** gzips responses above a threshold. It is worth it for JSON of any size and pointless for images, video or anything already compressed. It has no Brotli support. It also buffers, which breaks server-sent events unless you call `res.flush()` after each write, and it is redundant if the proxy in front already compresses - doing it twice costs CPU for nothing.
- **cors** must run before your routes, because a preflight `OPTIONS` never reaches them. `credentials: true` cannot be combined with a wildcard origin; browsers reject that pairing, so list your origins explicitly.
- **`express.json`** has a 100 KB default body limit. Raise it deliberately if you accept larger payloads, and do not raise it globally because one endpoint takes uploads - a body limit is the cheapest denial-of-service defence you have. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) covers the rest of that surface.

Logging goes first so that a request which fails inside another middleware is still logged. Log in JSON to stdout: the panel console shows it live, and structured lines are the difference between grepping and guessing. What to keep and for how long is in [logs worth keeping](https://www.renode.space/blog/logs-worth-keeping).

#### Errors, and the handler you probably do not have

Express matches an error handler by arity: four arguments, registered after everything else.

```js
app.use((req, res) => {
  res.status(404).json({ error: "not_found" });
});

app.use((err, req, res, next) => {
  req.log.error({ err }, "request failed");
  if (res.headersSent) return next(err);
  res.status(err.status || 500).json({ error: "internal_error" });
});
```

Three details decide whether this works.

**Async handlers.** In Express 4, a rejected promise inside `async (req, res) => {}` is not caught by Express at all. It becomes an unhandled rejection, which in current Node terminates the process - so one failed database call takes down every in-flight request. The fixes are to wrap handlers, to use a package that patches this, or to move to Express 5, which forwards rejections from async handlers to your error middleware. If you do upgrade, be aware that Express 5 also changed its route pattern parser, so a few older path patterns need rewriting; check the migration notes if routes stop matching.

**The response body.** Express's built-in error handler includes the stack trace in the response unless `NODE_ENV` is `production`. A stack trace tells an attacker your directory layout, your dependency versions and often your database driver. Set `NODE_ENV=production` on the Startup tab and return an error code rather than a message from the exception.

**Process-level handlers.** Log and then decide, rather than swallowing:

```js
process.on("unhandledRejection", (err) => {
  console.error("unhandled rejection", err);
});
process.on("uncaughtException", (err) => {
  console.error("uncaught exception", err);
  process.exit(1);
});
```

An uncaught exception leaves the process in an unknown state, so exiting and letting the platform restart it is the correct response. What is not correct is catching it and carrying on, which is how an API ends up serving requests from a process with a half-closed database pool.

#### Configuration that fails fast

Read configuration once, at boot, and refuse to start when something is missing:

```js
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((name) => !process.env[name]);

if (missing.length) {
  console.error(`missing configuration: ${missing.join(", ")}`);
  process.exit(1);
}
```

A process that exits in two seconds with a clear line is infinitely easier to diagnose than one that starts, accepts traffic, and returns 500s because a connection string was `undefined`. It also interacts well with restart policies: a server that cannot start does not sit in a loop pretending to work.

Secrets belong in environment variables on the Startup tab, not in the repository and not in a committed `.env`. [Where to put secrets on an application server](https://www.renode.space/blog/environment-variables-and-secrets) makes the case and covers what to do the day one leaks. Database credentials come from the panel when you create a database slot - app plans include two - and the connection is pooled, which is where most APIs make their first capacity mistake. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) explains why a pool of 100 on a small database is slower than a pool of 10.

#### Starting and stopping without dropping requests

Every deploy stops your process. Whether that costs your users an error depends on what happens in the last two seconds.

```js
const server = app.listen(port);

server.keepAliveTimeout = 65_000;
server.headersTimeout = 70_000;

process.on("SIGTERM", () => {
  server.close(() => process.exit(0));
  server.closeIdleConnections();
  setTimeout(() => process.exit(1), 10_000).unref();
});
```

`server.close()` stops accepting new connections and waits for in-flight requests to finish. On its own it can hang, because keep-alive connections sitting idle are still connections; `closeIdleConnections()` (Node 18.2 and later) clears those, and the timer is the backstop for a request that never ends.

The two timeouts are the fix for a specific and maddening symptom: occasional 502s under light traffic, with nothing in the application log. Node's default `keepAliveTimeout` is 5 seconds. If the proxy in front holds idle connections longer than that, it will sometimes send a request down a connection Node is closing at the same instant, and the proxy reports a bad gateway. Making Node's keep-alive window longer than the proxy's removes the race, and `headersTimeout` must be larger again or it closes the connection first.

Add a health endpoint that does not touch the database:

```js
app.get("/healthz", (req, res) => res.json({ ok: true, uptime: process.uptime() }));
```

Liveness answers "is this process alive", and that is all it should do. A health check that runs a query turns a slow database into a restart loop, which turns a degraded service into an outage. If you want to expose readiness, make it a second, separate endpoint. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) goes through the distinction.

#### Deploying it

The start command installs from the lockfile and runs one process:

```bash
npm ci --omit=dev && node dist/server.js
```

`npm ci` installs exactly what `package-lock.json` says and fails if the lockfile and the manifest disagree, which is what you want on a server; `npm install` resolves something new and is how an untouched app breaks on restart. If the project is TypeScript, either compile in CI and ship the output or drop `--omit=dev`, because the compiler is a devDependency. [npm ci vs npm install](https://www.renode.space/blog/npm-ci-vs-npm-install) has the detail.

Connect the repository rather than uploading files. On RE:NODE that is GitHub, through a GitHub App with short-lived tokens so private repositories work, with two independent switches: pull the branch on every start, and deploy on push, which restarts a server that was already running. One row per deploy tells you which push is live. The walkthrough is in [deploy a Node.js app from GitHub](https://www.renode.space/blog/deploy-a-node-app-from-github).

You do not need a process manager. A panel already restarts the process when it exits, shows the log, and graphs memory and CPU against the limits, which is most of what people install PM2 for - [PM2 or a hosting panel](https://www.renode.space/blog/pm2-vs-a-hosting-panel) is the honest comparison. You probably do not need clustering either: `node:cluster` multiplies processes to use multiple cores, and on a plan with half a core it adds memory and context switching for negative benefit. Measure first, and read [why your Node app dies at 2 GB on a 4 GB plan](https://www.renode.space/blog/node-memory-limits-explained) before assuming memory behaves the way you expect.

A restart is not instant, so a deploy costs a short gap. With one process there is no way to make that zero, but you can make it small: keep the install fast, deploy when traffic is low, and read [zero-downtime deploys on a server that only has one of everything](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) for the techniques that genuinely work on one machine.

#### What breaks first under load

An API that is fine at ten requests a second and unusable at fifty usually fails in one of four ways, and none of them is fixed by a bigger plan.

**Outbound calls with no timeout.** The default for `fetch` is to wait indefinitely. One slow upstream then holds every request that depends on it, connections pile up, and the process runs out of memory holding them. Give every outbound call a deadline:

```js
const response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
```

Reuse connections while you are there. Node's global `fetch` keeps connections alive per origin, but a library that creates a new agent per call pays a TLS handshake every time, which at a few hundred requests a minute is a measurable slice of your CPU.

**No limit on concurrency.** Ten simultaneous requests that each open a database connection and allocate a megabyte are fine. Five hundred are not. The pool is the natural place to bound this, because a request waiting for a connection is cheap and a request holding one is not. Size the pool to what the database can actually serve, not to what your app would like.

**Nothing cacheable is marked cacheable.** A `Cache-Control` header on a response that changes hourly removes most of the traffic before it reaches you, and it costs one line. `ETag` is on by default in Express, which turns repeat requests into 304s, but only helps if clients revalidate. [HTTP caching headers explained](https://www.renode.space/blog/http-caching-headers-explained) is the full picture; the short version is that the cheapest request is the one you never serve.

**Synchronous work on the event loop.** Parsing a large JSON payload, hashing a password with a high cost factor, resizing an image, building a PDF: while any of these runs, the process serves nobody. Password hashing should use the asynchronous API of whichever library you chose. Everything heavier belongs in a worker or a queue.

Above all this sits the memory limit. On RE:NODE, a container that reaches its limit is stopped and restarted clean rather than left to swap, so a leak shows up as a restart every few hours rather than a slow decline, and three unexpected restarts in an hour raise a warning and open a ticket automatically. That behaviour is a good default and a reason to watch the memory graph in the console after every deploy rather than only when something is already wrong.

#### FAQ

##### Why does my API work locally but time out on the server?

Almost always the bind address. `localhost` inside a container is not reachable from outside it. Listen on `0.0.0.0`, or omit the host argument entirely, and use the port from the environment rather than a hard-coded one.

##### Do I need nginx in front of Express?

You need something that terminates TLS, and on a managed platform that is already there. Running your own nginx inside the container duplicates it. What you do need is `trust proxy` set correctly so your app knows what the proxy in front of it did.

##### Is NODE_ENV=production actually important?

Yes. It stops Express returning stack traces in error responses, enables view caching, and is read by a long list of libraries to disable development behaviour. It is one environment variable and it changes real behaviour.

##### Why do I get occasional 502 errors with nothing in my logs?

The keep-alive race described above. Node closes an idle connection at the same moment the proxy reuses it. Set `server.keepAliveTimeout` above the proxy's idle timeout and `server.headersTimeout` above that.

##### Should I run several Node processes with cluster?

Only with more than one core to use, and only after measuring. A single event loop handles a large amount of I/O-bound work; clustering helps when you are CPU-bound, and costs memory per process. [Sizing a web app for launch day](https://www.renode.space/blog/sizing-a-web-app-for-launch-day) turns traffic estimates into a number of workers.

##### Where should uploads and generated files go?

Not in the deploy directory, because the next pull is in the way. Write to a path outside the tracked tree, or to object storage, and remember that disk on a small plan is measured in single-digit gigabytes. A database slot handles structured data; [database hosting](https://www.renode.space/database-hosting) covers the case where it outgrows the app server.

Node JS plans on RE:NODE start at 1 GB with 0.5 vCPU from $4 a month, with a port allocation, environment variables on the Startup tab, and deploys from a GitHub repository with pull-on-start and deploy-on-push. Every app plan includes a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, with the client address in `X-Forwarded-For`.

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

---

### Deploy Laravel to production: queues and cron

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/deploy-laravel-to-production

A Laravel deploy is six things in a fixed order: get the code on the server, install the production dependencies, put a real `.env` next to it with an application key, make `storage` and `bootstrap/cache` writable, build the caches, run the migrations. Everything that goes wrong afterwards is a variation on one of those six being skipped, done in the wrong order, or done on a machine with different paths from the one serving traffic. This guide is the whole sequence, the settings that decide whether the site is fast or merely working, and the errors that will otherwise cost you an evening.

It assumes a Linux server with nginx and PHP-FPM, which is what most shared and panel hosting is. The artisan commands are the same on any host, including one where you only have SFTP.

#### Before the code: PHP, extensions and the document root

Laravel 10 needs PHP 8.1 or newer. Laravel 11 and 12 need PHP 8.2 or newer. If you are choosing now, take the newest PHP your dependencies support, because the jump from 7.4 to 8.x is the single largest free speed-up available to a PHP application, and OPcache in 8.x is better at keeping compiled code in memory.

The extensions Laravel documents as required are `ctype`, `curl`, `dom`, `fileinfo`, `filter`, `hash`, `mbstring`, `openssl`, `pcre`, `pdo`, `session`, `tokenizer` and `xml`. Almost every real application also wants `intl` for dates and number formatting, `zip` for archive handling, and `gd` or `imagick` if it touches images. Check what you have before you upload anything:

```bash
$ php -v
$ php -m
$ php -i | grep -E "opcache.enable|memory_limit|upload_max_filesize"
```

The document root is the setting that catches everyone arriving from a CMS. It must point at the `public/` directory inside your project, not at the project root. Everything above `public/` - your `.env`, `vendor`, `storage`, `app` - is meant to be unreachable over HTTP. If the root is wrong you get one of two symptoms: a directory listing of your source code, or a page that loads but 404s on every route except `/`. Neither is subtle once you know to look for it.

If your host fixes the document root and will not move it, you have two honest options: put the contents of `public/` in the served directory and edit `index.php` to point `require` at the real paths one level up, or pick a host where you control the root. The first works and is ugly; plenty of production sites run that way.

> [!WARNING]
> If `.env` is ever reachable over HTTP, treat every credential in it as public: rotate the database password, the app key, and any API token. A leaked `.env` is the most common way small PHP sites get taken over. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers where they should live instead.

#### The .env file and the application key

`.env` is not deployed with your code. It is created once on the server and never committed. The minimum for production:

.env:

```env
APP_NAME="Example"
APP_ENV=production
APP_KEY=base64:...
APP_DEBUG=false
APP_URL=https://example.com

LOG_CHANNEL=stack
LOG_LEVEL=warning

DB_CONNECTION=pgsql
DB_HOST=db.example.net
DB_PORT=5432
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD=...

SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database
```

`APP_DEBUG=false` is not optional. With it on, any uncaught exception renders a page containing your file paths, your query, and a good part of your environment. Set it, then confirm it by causing a deliberate 500 and checking you get the plain error page.

`APP_KEY` is a 32-byte key used for every encrypted cookie and every `Crypt::` call. Generate it on the server once:

```bash
$ php artisan key:generate --force
```

Changing it later invalidates all existing sessions and makes previously encrypted columns unreadable, so generate it before launch and then leave it alone. Back it up somewhere other than the server.

Laravel ships database drivers named `mysql`, `mariadb`, `pgsql`, `sqlite` and `sqlsrv`. Use whichever matches the database you actually have, and take the host, port, name, user and password from wherever your host generated them rather than guessing. On RE:NODE, web plans include database slots created from the panel with the host, user and password generated for you, and an "Open in phpMyAdmin" button that signs in with a token good for 60 seconds. The separate database line is PostgreSQL or MongoDB, reached on its own host and port.

The queue, cache and session drivers deserve a decision rather than a default. `database` for all three is the right starting point on a single small server: it needs nothing extra, survives restarts, and is easy to inspect. `file` sessions work but leave thousands of files in `storage/framework/sessions`, and a shared cache stops being shared the moment you run two processes. Note that Laravel 11 renamed the cache key from `CACHE_DRIVER` to `CACHE_STORE`; on 10 and earlier it is still `CACHE_DRIVER`, and setting the wrong one silently leaves you on the default.

#### Permissions, storage and the public symlink

Two directories have to be writable by the user PHP-FPM runs as, and only those two: `storage` and `bootstrap/cache`. Everything else can be read-only.

```bash
$ chmod -R ug+rwX storage bootstrap/cache
$ chown -R www-data:www-data storage bootstrap/cache
```

The user name varies by host. On a container it is often not `www-data` at all, and on shared hosting your SFTP user and the PHP user are usually the same, which makes this step a no-op. `777` is the answer people find on forums and it is the wrong one: it makes every uploaded file executable by anybody on the machine.

Files uploaded by the application go to `storage/app/public` by default, which is not inside the document root. The symlink is what exposes them:

```bash
$ php artisan storage:link
```

That creates `public/storage` pointing at `storage/app/public`. It is a real symlink, so it does not survive a deploy that uploads a fresh `public/` directory over SFTP, and some archive tools refuse to create it. If uploaded images 404 after a deploy and the files exist on disk, this is why. Re-run the command, or create the link by hand.

If your host does not allow symlinks at all, set `FILESYSTEM_DISK=public` and change the disk's root to a directory inside `public/` in `config/filesystems.php`. It is not elegant, but it beats a broken media library.

#### Composer, assets and the caches that make it fast

Install dependencies the production way:

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

`--no-dev` skips everything in `require-dev`. If the site breaks with a "Class not found" naming a debug or testing package, that package is used in production code and belongs in `require`, not `require-dev`. `--optimize-autoloader` builds a class map so PHP stops searching the filesystem on every autoload, which is worth a measurable slice of your response time on a large application.

Front-end assets are built with Vite, and the output lands in `public/build`. On a 1 GB plan, `npm run build` on a big project is a plausible way to hit the memory limit and have the container stopped, so building locally or in CI and uploading `public/build` is both faster and safer. The server does not need Node at all if you do that.

Then the caches. Each one turns a pile of file reads and parsing into a single included PHP array:

| Command | What it caches | Safe to run |
| --- | --- | --- |
| `config:cache` | Every file in `config/` into one array | Always, in production |
| `route:cache` | All route definitions | Unless routes use closures |
| `view:cache` | Blade templates compiled ahead of time | Always |
| `event:cache` | Event and listener discovery | Always |
| `optimize` | Runs the set above, version depending | Always, in production |

```bash
$ php artisan optimize
```

In recent versions `php artisan optimize` runs the config, event, route and view caches together; in older ones it did less. Run `php artisan optimize --help` on your version rather than assuming. The reverse, for when you need to rebuild, is `php artisan optimize:clear`.

The consequence people get bitten by: **once the config is cached, `env()` returns null everywhere except inside `config/` files.** The cached array is built from the config files, with `env()` already resolved. If your application calls `env('STRIPE_KEY')` in a controller, it works locally and returns null in production. The fix is to add the value to a config file and call `config('services.stripe.key')` instead. This is one of the top two causes of "it works on my machine".

Route caching fails loudly if any route uses a closure instead of a controller. The error names the file. Move the closure into a controller, or skip `route:cache` and accept the cost.

#### Migrations without breaking the site

In production, migrations must not prompt:

```bash
$ php artisan migrate --force
```

Without `--force`, artisan asks for confirmation and, in a non-interactive deploy script, either hangs or aborts. `--force` only means "do not ask"; it does not skip anything.

The order matters. A deploy that ships new code before the column it needs exists gives every visitor a 500 for the length of the gap. Two patterns avoid it:

1. **Maintenance mode**, for small sites where a minute of downtime is acceptable. `php artisan down --secret=some-long-string` puts up the 503 page for everyone but lets you through at `https://example.com/some-long-string`. Deploy, migrate, then `php artisan up`.
2. **Backwards-compatible migrations**, for everything else. Add the column and deploy code that tolerates it being null, backfill, then deploy code that requires it, then drop the old column in a later release. It is three deploys instead of one and nobody sees an error. [Migrations without downtime](https://www.renode.space/blog/migrations-without-downtime) goes through the pattern properly.

Take a database backup before every migration that drops or renames anything. `php artisan migrate:rollback` only works if the migration has a working `down()` method, and a surprising number do not. On RE:NODE the Backups tab takes one on demand or on a schedule and restores it with a button, and backups are stored off the machine they protect - which is the only kind that helps when the machine is the problem. [Database backups and restores](https://www.renode.space/blog/database-backups-and-restores) has the rest.

#### Queues and the scheduler

Anything slow in a web request - sending mail, resizing an image, calling a third-party API - belongs on a queue. A queue needs a worker process that outlives the request:

```bash
$ php artisan queue:work --queue=high,default --tries=3 --max-time=3600 --sleep=3
```

`queue:work` is a long-running PHP process, so it holds your code in memory: after a deploy it keeps running the old code until you tell it otherwise. `php artisan queue:restart` signals every worker to finish its current job and exit, and your process manager starts them again with the new code. Put that line in your deploy script and never think about it again.

`--max-time=3600` makes the worker exit after an hour regardless, which is the cheapest defence against a slow memory leak in a package you do not control. `--tries=3` stops a permanently failing job from being retried forever; failed jobs land in the `failed_jobs` table, and `php artisan queue:retry all` puts them back. Use `queue:listen` in development only: it reboots the framework for every job, which is convenient and several times slower.

The scheduler is the other process. Laravel's scheduler is one cron entry that runs every minute and decides for itself what is due:

```bash
* * * * * cd /var/www/example && php artisan schedule:run >> /dev/null 2>&1
```

On a container with no crontab, `php artisan schedule:work` does the same thing as a foreground process you keep running. That is usually the better fit for panel hosting, where the thing that stays alive is the server process itself. Do not schedule the same job from two machines: Laravel has `withoutOverlapping()` and `onOneServer()` for exactly that, and the second needs a shared cache store.

> [!TIP]
> `php artisan schedule:list` prints every scheduled task with its next run time. It is the fastest way to find out whether your cron is firing at all: if the list is right and nothing runs, the problem is the crontab, not your code.

On RE:NODE the Schedules tab takes a cron expression and runs ordered tasks against a server - a console command, a backup, a power action - which makes it the right place for the nightly backup and a weekly restart. [Cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) covers the five fields if the syntax is new.

#### HTTPS, proxies and URL generation

Almost every Laravel app in production sits behind something that terminates TLS: a reverse proxy, a load balancer, a CDN. That machine speaks HTTPS to the visitor and plain HTTP to your application, which leads to two predictable bugs.

The first is mixed content. Your app sees an HTTP request, so `url()`, `asset()` and `route()` generate `http://` links, and the browser blocks them on an HTTPS page. The second is that every visitor appears to come from the proxy's address, which quietly breaks rate limiting, logging and anything geographic.

Both are fixed by trusting the proxy so Laravel reads `X-Forwarded-Proto` and `X-Forwarded-For`. On Laravel 11 and 12 that is in `bootstrap/app.php`:

bootstrap/app.php:

```php
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})
```

On Laravel 10 and earlier it is `$proxies = '*';` in `app/Http/Middleware/TrustProxies.php`. Trusting `*` is correct when the only route to your application is through the proxy, and wrong when your origin port is reachable from the internet, because then anyone can forge the header. Setting `APP_URL=https://example.com` covers URL generation in console commands and queued jobs, which have no request to read the scheme from.

RE:NODE's app and web plans include a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, inside a 21-day window, with the real client address arriving in `X-Forwarded-For`. [What a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains the request 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.

#### A repeatable deploy

Write the sequence down once and run the same thing every time. Manual deploys drift, and the drift is what breaks at midnight.

```bash
$ php artisan down --secret="$(cat /var/www/deploy-secret)"
$ git pull --ff-only origin main
$ composer install --no-dev --optimize-autoloader --no-interaction
$ php artisan migrate --force
$ php artisan optimize
$ php artisan queue:restart
$ php artisan up
```

Where you have no shell, the same steps happen through SFTP and whatever console your host gives you, in the same order. One caution: build the caches on the machine that serves them. `bootstrap/cache/packages.php` and the compiled views contain absolute paths, so caches generated on your laptop and uploaded can point at directories that do not exist on the server.

Keep the last working release. The fastest rollback is the previous directory plus a `migrate:rollback` you have actually tested, and the second fastest is a restore from the backup you took five minutes ago. [Zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers the symlinked-release pattern when you want the deploy itself to be invisible.

#### Troubleshooting

**A blank page or a bare 500 with no detail.** That is `APP_DEBUG=false` doing its job. The detail is in `storage/logs/laravel.log`. If that file is empty, PHP could not write it: check the permissions above and the PHP-FPM error log.

**"No application encryption key has been specified".** `APP_KEY` is missing or the config cache was built before it existed. Run `php artisan key:generate --force`, then `php artisan config:cache` again.

**Everything 404s except the home page.** The document root is not `public/`, or nginx has no `try_files $uri $uri/ /index.php?$query_string;` line in the location block.

**419 Page Expired on every form.** The session cookie is not coming back. Usual causes: `SESSION_DOMAIN` set to the wrong host, `SESSION_SECURE_COOKIE=true` while the app still thinks it is on HTTP, or a cached config from a different environment.

**A setting change does nothing.** The config cache is stale. `php artisan optimize:clear`, then rebuild. This also applies to a `.env` edit: with the config cached, `.env` is not read on a request at all.

**Uploaded images 404 but exist on disk.** The `public/storage` symlink is gone. Re-run `php artisan storage:link`.

**The queue processes nothing after a deploy.** The worker died and nothing restarted it, or it is still holding the old code. Check the process is alive, then `php artisan queue:restart`.

**The site gets slower under load, then restarts.** You are at the memory limit. On RE:NODE the container is stopped at the limit and restarted clean rather than left to swap, which is faster to recover from but does mean in-flight requests are lost. Count your PHP-FPM workers times their real memory use before blaming the framework, and read [sizing a web app for launch day](https://www.renode.space/blog/sizing-a-web-app-for-launch-day).

#### FAQ

##### Do I need Composer installed on the server?

Not strictly. You can run `composer install --no-dev --optimize-autoloader` locally or in CI and upload the `vendor` directory, as long as the PHP version you built against matches the one on the server. Running it on the server is simpler and avoids that mismatch.

##### Why does env() return null in production?

Because the config is cached. `php artisan config:cache` resolves every `env()` call inside `config/` once and writes the result, and `env()` outside those files then reads an environment that is no longer populated. Move the value into a config file and read it with `config()`.

##### How much memory does a Laravel site need?

A small application is comfortable in 1 GB, including PHP-FPM workers and a database on the same box. Add memory when you add concurrency rather than features: each PHP-FPM worker holds its own copy of your application, so four workers at 80 MB is 320 MB before anything else.

##### Can I run the scheduler without cron?

Yes. `php artisan schedule:work` runs in the foreground and fires due tasks every minute, which is the practical answer on hosting where you cannot edit a crontab. It has to be kept running like any other long-lived process.

##### Should the queue worker and the web server share a plan?

For a small site, yes - a worker at idle costs almost nothing. Split them when a job is heavy enough to starve web requests of CPU, because a queue burst and a traffic spike arriving together is how a one-container setup falls over.

##### What do I do about the .env when I change hosts?

Copy it by hand, not with the code. Change the database credentials to the new ones, keep `APP_KEY` exactly as it was so sessions and encrypted data survive, then clear and rebuild the caches on the new machine.

The Engines line is sized for PHP applications like Laravel: nginx and PHP, SFTP and a full file manager for the deploy, two database slots created from the panel with generated credentials, backup slots on every tier, and a proxy slot that issues and renews the certificate for your domain once the A record points at it.

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

---

### SPF, DKIM and DMARC: keep mail out of spam

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/email-spf-dkim-dmarc-explained

Mail from your domain lands in spam because the receiving server cannot prove it came from you. Three DNS records fix that: `SPF` lists the servers allowed to send for your domain, `DKIM` attaches a cryptographic signature the receiver can verify against a public key in your zone, and `DMARC` tells the receiver what to do when neither of them lines up with the address your reader actually sees. All three are `TXT` records. None of them is difficult. What is difficult is the concept in the middle - alignment - and almost every "I set up SPF and it still fails" story is that concept being missed.

One thing to get out of the way first, because it changes what you should be reading: RE:NODE does not host email. No mailboxes, no MX, no SMTP relay, and no DNS zones either - the records below are made wherever your domain's nameservers point, which is usually your registrar. What we host is the application or website that sends the mail, and that part is covered at the end.

#### Two From addresses, and why that is the whole problem

An SMTP message has two senders, and they do not have to match.

The **envelope sender** is what the sending server declares in the `MAIL FROM` command during the SMTP conversation. It is where bounces go, it is recorded in the `Return-Path` header, and nobody ever sees it in a mail client. On a form mailer it is frequently something like `bounces@sendgrid.net` or `www-data@server42.hostingco.net`.

The **header From** is the line inside the message that says `From: Anna <anna@example.com>`. It is the only one a human reads, and it is the one a phisher wants to control.

SPF checks the envelope sender. DKIM signs the message and names a signing domain. Neither of them, on its own, says anything about the address in the `From:` line - which is the address being abused. DMARC exists to close that gap: it requires that one of the two passing checks refers to the same domain as the visible `From:`, and it lets you publish what should happen when neither does.

```diagram
title: What a receiving server checks before it delivers
sender = Your sender | app, relay or mailbox
receiver = Receiving server | Gmail, Outlook
spf = SPF check | envelope domain
dkim = DKIM check | selector public key
dmarc = DMARC policy | _dmarc TXT record
inbox = Inbox, spam or reject
sender -> receiver : SMTP on 25
receiver -> spf
receiver -> dkim
spf -> dmarc : aligned with From?
dkim -> dmarc : aligned with From?
dmarc -> inbox : p= decides
```

#### SPF: which servers are allowed to send

SPF is one `TXT` record at your domain apex listing every source that may put your domain in the envelope sender.

TXT record at example.com:

```text
v=spf1 include:_spf.google.com include:sendgrid.net ip4:203.0.113.10 ~all
```

Read it left to right. The receiver checks each mechanism against the connecting server's IP address and stops at the first match.

| Mechanism | Costs a lookup | What it matches |
| --- | --- | --- |
| `ip4:` / `ip6:` | No | A literal address or CIDR range |
| `a` | Yes | The `A` records of the domain |
| `mx` | Yes | The addresses of your `MX` hosts |
| `include:` | Yes | Another domain's SPF record, evaluated in place |
| `exists:` | Yes | A constructed name that resolves |
| `all` | No | Everything, so it goes last |

The qualifier in front of `all` is the verdict for everything that did not match: `-all` is a hard fail, `~all` is a soft fail, `?all` is neutral, and `+all` means "anyone may send as me", which is worse than having no record at all.

Four rules decide whether an SPF record works:

1. **One record per domain.** Two `TXT` records starting `v=spf1` produce a permanent error and everything fails. If you add a provider, edit the existing record; do not create a second one.
2. **Ten DNS lookups, total.** `include`, `a`, `mx`, `exists`, `ptr` and `redirect` each cost one, and every `include` spends more inside itself. Past ten the result is `permerror`, which most receivers treat as a fail. A record with four mail providers in it is usually already over. Replace what you can with `ip4:` ranges, or use a flattening service if you must.
3. **The `ptr` mechanism is deprecated.** Remove it. It is slow, unreliable, and RFC 7208 tells receivers they may skip it.
4. **SPF does not survive forwarding.** When a university alias forwards your mail on, the forwarder's server connects to the final destination with your domain still in the envelope, from an address you never listed. SPF fails and there is nothing you can do about it. This is not a bug in your setup - it is the reason DKIM exists.

Start with `~all` while you are still discovering who sends for you, and move to `-all` only once DMARC reports show you have found everybody.

#### DKIM: a signature that travels with the message

The sending server hashes the body and a chosen set of headers, signs the hash with a private key, and adds a `DKIM-Signature` header naming the signing domain (`d=`) and the key selector (`s=`).

a DKIM-Signature header, trimmed:

```text
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
  s=s1; h=from:to:subject:date:message-id; bh=47DEQpj8HBSa...;
  b=Xk9pLm2Qv8...
```

The receiver takes `d=` and `s=`, looks up `s1._domainkey.example.com`, and finds the public key:

TXT record at s1._domainkey.example.com:

```text
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
```

Points that matter in practice:

- **You do not generate this yourself in most cases.** Your mail provider generates the key pair, keeps the private half, and hands you the `TXT` record to publish. Google Workspace, Microsoft 365, Postmark, Mailgun and the rest all work this way, and each has its own selector.
- **Every sender needs its own selector.** Transactional mail from your application, marketing mail from a campaign tool and normal mail from your mailboxes are three different signing keys at three different selectors, all in the same zone. They do not conflict.
- **A 2048-bit key does not fit in one DNS string.** A `TXT` record is made of chunks of at most 255 characters, so the key has to be split. Nearly every DNS editor does this silently; a few make you split it yourself with quotes. If publishing appears to succeed but verification fails, this is the first thing to check.
- **DKIM survives forwarding, until something edits the message.** A mailing list that appends a footer or prefixes the subject changes bytes that were signed, and the signature breaks. That is what `ARC` headers were invented for, and it is out of your hands as a sender.
- **Rotate keys occasionally.** Publish a new selector, switch the sender to it, wait a week for in-flight mail, then remove the old record.

#### DMARC: the policy that ties the two together

DMARC is a single `TXT` record at `_dmarc` under your domain.

TXT record at _dmarc.example.com:

```text
v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r; pct=100
```

| Tag | Default | What it does |
| --- | --- | --- |
| `p` | required | `none`, `quarantine` or `reject` for the domain |
| `sp` | same as `p` | A different policy for subdomains |
| `rua` | - | Where daily aggregate XML reports are sent |
| `ruf` | - | Where per-message failure reports go, if the receiver sends any |
| `pct` | `100` | Percentage of failing mail the policy is applied to |
| `adkim` | `r` | DKIM alignment: `r` relaxed, `s` strict |
| `aspf` | `r` | SPF alignment: `r` relaxed, `s` strict |

`p=none` changes nothing about delivery. It only asks receivers to send you reports, which is exactly what you want at the start: a stream of evidence about who is sending as your domain before you tell anybody to throw mail away. `p=quarantine` means spam folder. `p=reject` means the receiving server refuses the message during the SMTP conversation, so the sender gets a bounce.

The aggregate reports at `rua` are gzipped XML, one per receiver per day, and they are not meant to be read by eye. Feed them into any DMARC report parser - there are free ones - and what you get is a table of sending IPs, their SPF and DKIM results, and whether each aligned. That table is the whole point of the exercise.

> [!NOTE]
> If you send reports to an address at a different domain, that domain has to authorise it with its own record. To send `example.com` reports to `dmarc@agency.net`, the record `example.com._report._dmarc.agency.net` must exist with the value `v=DMARC1`. Without it, compliant receivers silently drop the reports and you sit there wondering why none arrive.

#### Alignment is the part that catches people

This is the concept that makes sense of every confusing result.

DMARC passes when **at least one** of SPF or DKIM passes **and** the domain it passed for matches the domain in the visible `From:` header. Both halves are required. A message can pass SPF cleanly and still fail DMARC, and that combination is the single most common support ticket in this whole subject.

Here is how it happens. Your contact form runs through a transactional provider. The provider sets the envelope sender to `bounce@mail.provider.net`, because bounces come back to them. SPF is checked against `provider.net`, and it passes - their record is fine. But the `From:` header says `hello@example.com`. `provider.net` is not `example.com`, so that pass is not aligned, and it contributes nothing to DMARC. If DKIM is signing with `d=provider.net` as well, DMARC fails outright and your `p=reject` throws away your own contact form.

The fix is one of two things, and every serious provider offers both:

- **A custom return path.** You publish a `CNAME` such as `bounces.example.com` pointing into the provider's infrastructure, they use it as the envelope domain, and SPF now passes for a subdomain of `example.com`. Under relaxed alignment - the default - a subdomain aligns with the parent, so DMARC is satisfied.
- **DKIM signed with your domain.** You publish the provider's selector under `example.com`, and they sign with `d=example.com`. This is the more robust of the two, because it also survives forwarding.

Relaxed alignment (`r`) accepts any subdomain of the organisational domain: `mail.example.com` aligns with `example.com`. Strict (`s`) requires an exact match. Leave both relaxed unless you have a specific reason, because strict alignment breaks custom return paths for no security gain that matters at this level.

#### Rolling it out without losing mail

The order matters. Publishing `p=reject` on day one, on a domain whose senders you have not inventoried, means your invoices stop arriving and nobody tells you.

1. **List every sender.** Mailboxes, the website's contact form, the application's password resets, the invoicing tool, the CRM, the newsletter, the helpdesk, the monitoring alerts, and whatever somebody in accounts set up three years ago. This step takes longer than all the others.
2. **Get SPF and DKIM right for each of them.** One SPF record covering all of them within the ten-lookup budget; one DKIM selector per sender, signing with your domain wherever the provider allows it.
3. **Publish `p=none` with a `rua` address.** Leave it for two to four weeks and read the reports.
4. **Fix what the reports show.** There will be a sender you forgot. There always is.
5. **Move to `p=quarantine`.** Use `pct=25`, then `pct=100`, if you want a gentler ramp. Watch the reports for another two weeks.
6. **Move to `p=reject`.** Keep reading the reports. This is not a thing you finish.

Two extras worth knowing about: the receiving side can be hardened with `MTA-STS`, which tells senders your domain requires TLS, and a `BIMI` record - the one that puts a logo beside your mail in some clients - is only honoured on domains already at `p=quarantine` or `p=reject`. Neither is a starting point.

#### Checking it from the outside

Everything here is public, so you can verify all of it yourself with `dig` - no tooling required.

```bash
$ dig TXT example.com +short
$ dig TXT s1._domainkey.example.com +short
$ dig TXT _dmarc.example.com +short
$ dig MX example.com +short
```

On Windows, `Resolve-DnsName example.com -Type TXT` does the same job. If a record you just published does not appear, it is cached - the answer you get is as old as the TTL allowed. Query your authoritative nameserver directly to bypass that, as described in [nameservers vs DNS records](https://www.renode.space/blog/nameservers-vs-dns-records), and see [DNS records explained](https://www.renode.space/blog/dns-records-explained) for why the cache is always the confusing part.

The other half of the check is to send a message to an account you control and read the headers. In Gmail, Show original prints the verdicts at the top; in most clients, look for the `Authentication-Results` header:

```text
Authentication-Results: mx.google.com;
  spf=pass (google.com: domain of bounces.example.com designates
  203.0.113.10 as permitted sender) smtp.mailfrom=bounces.example.com;
  dkim=pass header.i=@example.com header.s=s1;
  dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=example.com
```

Three passes and `header.from` matching your domain is the finished state. Anything else tells you exactly which of the three to go back to.

#### What else decides the spam folder

Authentication gets you considered. It does not get you delivered. Once SPF, DKIM and DMARC pass, the things that still put mail in spam are:

- **Reputation of the sending IP and domain.** New sending domains start with nothing and are treated cautiously. Send a slow, steady volume for a few weeks rather than ten thousand messages on day one.
- **Reverse DNS.** The sending IP should have a `PTR` record that resolves back to the name the server uses in `HELO`. Shared relays handle this; a server you configured yourself often does not.
- **Complaint rate.** Gmail's published threshold for bulk senders is a spam-complaint rate below 0.3%, with 0.1% as the target. One bad list purchase ruins a domain for months.
- **Unsubscribe handling.** Since 2024, senders of more than 5,000 messages a day to Gmail or Yahoo must support one-click unsubscribe on promotional mail and honour it within two days, on top of SPF, DKIM and a DMARC record.
- **Bounce handling.** Keep sending to addresses that hard-bounce and you are telling every receiver you do not maintain your list.
- **Content and links.** Shortened links, a single large image with no text, and link domains with poor reputation all count against you.

#### Sending mail from an application you host

If your Node, Python, PHP or Laravel application needs to send password resets and order confirmations, do not try to make the server itself into a mail server. Outbound port `25` is blocked by most networks, a fresh IP has no reputation, and you would be taking on spam filtering, queueing and bounce processing as a side project.

Use a transactional provider over SMTP submission - port `587` with `STARTTLS`, or `465` with implicit TLS - or their HTTP API. Configure it with the custom return path and your own DKIM selector from the alignment section above, so DMARC passes. Keep the credentials out of the repository and in the panel's environment variables, as in [environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets); an SMTP password committed to Git is one of the fastest ways to have your domain used to send spam.

On RE:NODE this is the boundary worth being explicit about: we host the application or site that sends the mail, on app and web plans with a reverse-proxy slot, environment variables on the Startup tab and database slots in the panel. We do not host mailboxes, we do not run MX, we do not relay SMTP, and we do not host DNS zones. Your `MX` record points at your mail provider, your `A` record points at us, and the two are independent - which is also the answer to "will moving my website break my email". It will not, as long as you only change the records that concern the website. [Nameservers vs DNS records](https://www.renode.space/blog/nameservers-vs-dns-records) covers which records those are, and if the site itself is a WordPress install, [WordPress security hardening](https://www.renode.space/blog/wordpress-security-hardening) covers the plugin that is usually sending the mail.

#### FAQ

##### Do I need all three records?

DMARC needs at least one of SPF or DKIM to pass in an aligned way, so strictly you could run with two. In practice publish all three: SPF fails on forwarding, DKIM fails on mailing lists, and having both means one of them usually survives. DMARC without either is meaningless.

##### Will a DMARC record stop people spoofing my domain?

It stops mail that claims to be from your domain at receivers who honour DMARC, which now includes every large mailbox provider. It does nothing about lookalike domains, display-name spoofing, or a compromised account sending genuinely authenticated mail. It closes one door, and it is the door most commonly used.

##### Why does my mail pass SPF but fail DMARC?

Alignment. SPF passed for the envelope domain, which belongs to your sending provider, not for the domain in the visible `From:` header. Set up a custom return path at a subdomain of your own domain, or have the provider sign DKIM with your domain, and the pass will align.

##### How long until a change takes effect?

As long as the TTL on the record, plus however long receivers cache it. Lower the TTL to 300 seconds a day before you change anything, and expect to wait the old TTL out after you do. Nothing about mail authentication is instant.

##### Can I host email on the same plan as my website?

Not here. RE:NODE hosts the website or application; mailboxes, MX and SMTP relaying are somebody else's service. That separation is normal and it is also convenient: you can move the website between hosts without touching a single mail record.

##### What does p=none actually do?

Nothing to your delivery. It asks receivers to report on what they saw and to take no action on failures. It is the correct first step, and leaving a domain at `p=none` for years is still better than jumping to `p=reject` and discovering your senders at the same time as your customers do.

Web and app plans on RE:NODE include a reverse-proxy slot with an automatic certificate, environment variables on the Startup tab for your SMTP credentials, and database slots in the panel. Mail itself stays with your mail provider - we do not host mailboxes, MX or DNS zones, and your `MX` records are untouched by moving a site here.

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

---

### How much RAM does WordPress actually need

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/how-much-ram-does-wordpress-need

A small WordPress site - a blog, a brochure site, a portfolio, twenty plugins, a few thousand visits a day - is comfortable in 1 GB. A busy site with a page builder, a membership system or a few hundred products wants 2-4 GB. A WooCommerce shop doing real money with uncacheable cart and checkout traffic wants 4-8 GB and will use it. Those numbers are useless on their own, though, because WordPress does not consume memory as one blob. It consumes memory as a number of PHP processes multiplied by the size of each one, and both halves of that product are things you control. Once you can do that arithmetic you can size any WordPress site in about five minutes, and you can tell in advance whether a bigger plan will help or whether you are about to pay for a plugin problem.

#### The short answer, by kind of site

| Kind of site | RAM | PHP workers | The binding constraint |
| --- | --- | --- | --- |
| Blog or brochure, cached, light theme | 1 GB | 4-6 | Almost nothing |
| Business site, page builder, 25-40 plugins | 2 GB | 6-10 | Worker size |
| Membership, LMS, forum, logged-in users | 4 GB | 10-16 | Uncacheable requests |
| WooCommerce, under 1,000 products | 4 GB | 10-16 | Cart and checkout |
| WooCommerce, large catalogue or heavy traffic | 8 GB | 16-30 | Database, then workers |
| Multisite network | 4 GB and up | Per site | Whichever site is busiest |

Read those as starting points, in the same spirit as the numbers in [game server requirements by game](https://www.renode.space/blog/game-server-requirements-by-game): a figure to begin at and correct with real data, not a threshold. The rest of this post is how to do the correcting.

#### Where the memory actually goes

On a plan running the whole stack in one allocation, five things want memory and only one of them is WordPress:

- **The web server.** nginx is small, a few tens of megabytes. Apache with `mpm_prefork` and mod_php is not, because each Apache process carries a PHP interpreter.
- **PHP-FPM workers.** The big one. Each worker is a full PHP process that loads WordPress core, your theme and every active plugin on every request. This is where most of your memory goes and where all of the useful tuning is.
- **OPcache.** A single shared block of memory holding compiled PHP bytecode, sized once and used by every worker. Default 128 MB.
- **The database server.** Its own process with its own cache of table pages and indexes. On a small site a few hundred megabytes; on a large shop, as much as you can give it.
- **The operating system, and anything else you run.** 150-350 MB for a minimal Linux install before you start.

The important structural point: PHP workers are the only part that scales with traffic. Everything else is roughly fixed. So sizing WordPress is mostly the question of how many workers you can afford and how big each one is.

#### PHP workers are the real unit

A PHP-FPM pool runs a fixed number of worker processes. Each one handles one request at a time from start to finish. If ten requests arrive while all your workers are busy, they queue; if the queue is full, visitors get a 502 or a 504. That is what "the site went down under load" almost always is - not memory exhaustion, not the database, just more concurrent requests than workers.

The arithmetic for how many workers you need is short:

```text
workers needed = requests per second x average request time in seconds

12 req/s x 0.30 s = 3.6  ->  round up, plus headroom  ->  6 workers
12 req/s x 1.20 s = 14.4 ->  round up, plus headroom  ->  18 workers
```

The second line is the whole argument for performance work. The same traffic needs three times the memory when each request takes four times as long, and request time is a property of your theme and plugins rather than of your host.

The arithmetic for how big a worker is comes from the other direction:

```text
max_children = (RAM available for PHP) / (average worker RSS)

2 GB plan:  2048 - 250 (OS + nginx) - 400 (database) - 128 (OPcache) = 1270 MB
1270 MB / 110 MB per worker  =  11 workers
```

Typical worker sizes, measured as resident memory on a warm process:

- Plain WordPress, light theme, ten plugins: 40-70 MB
- Typical business site, page builder, thirty plugins: 90-150 MB
- WooCommerce, or a heavy multipurpose theme: 150-250 MB
- One badly behaved plugin importing a large file: whatever `memory_limit` allows

Set the result in the pool config rather than leaving the default, which is usually far too high for a small plan:

/etc/php/8.3/fpm/pool.d/www.conf:

```ini
pm = ondemand
pm.max_children = 11
pm.process_idle_timeout = 20s
pm.max_requests = 500
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm-slow.log
```

`pm = ondemand` starts workers only when needed and lets them exit when idle, which suits a small site with bursty traffic and keeps the idle memory floor low. `pm = dynamic` keeps a warm pool and is better for a site with steady traffic. `pm.max_requests` recycles a worker after a number of requests, which papers over slow memory growth in badly written plugins.

> [!WARNING]
> Setting `pm.max_children` higher than your memory allows does not buy capacity, it buys a crash. Every worker that starts is another 100-200 MB, and when the total passes the limit the kernel stops the container. On RE:NODE that means the server is stopped at the limit and restarted clean rather than left to thrash in swap, which is the kinder failure but is still a restart in the middle of somebody's checkout.

#### php.ini: memory_limit, OPcache and the settings that matter

`memory_limit` is not your plan size and it is not a reservation. It is the ceiling on what **one** PHP process may allocate before PHP kills the request with a fatal error. Setting it to 512M does not consume 512 MB; it means that one runaway request is allowed to consume 512 MB before being stopped.

That distinction matters because of the interaction: `memory_limit` multiplied by `pm.max_children` is your worst case. 512M times 20 workers is 10 GB on a 4 GB plan. In practice workers never all peak together, but a plugin that hits the limit on every request in a loop will find out for you.

WordPress adds its own layer on top, which confuses people who set `memory_limit` and see no change. In `wp-includes/default-constants.php`, WordPress defines `WP_MEMORY_LIMIT` at 40M for a single site and 64M for multisite, and `WP_MAX_MEMORY_LIMIT` at 256M for the admin area and cron. If the value in `php.ini` is higher, WordPress uses the higher one; if you need more than 256M inside the admin, you raise the WordPress constant as well:

wp-config.php:

```text
define( 'WP_MEMORY_LIMIT', '256M' );
define( 'WP_MAX_MEMORY_LIMIT', '512M' );
define( 'DISABLE_WP_CRON', true );
```

Sensible values for a normal site: `memory_limit = 256M` in `php.ini`, leave the WordPress constants alone unless something complains. 128M is enough for most sites and is the PHP default; 512M is a diagnostic setting for an import, not a permanent one. If you genuinely need more than 256M to render a page, the page is the problem. [PHP settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers the rest of the file.

OPcache is the highest-value memory you will spend, because it removes the parse and compile step from every request. Two of its defaults are wrong for WordPress and almost nobody changes them:

php.ini:

```ini
opcache.enable = 1
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 30000
opcache.revalidate_freq = 2
```

`opcache.max_accelerated_files` defaults to 10000. A WordPress install with thirty plugins routinely has more PHP files than that, and once the limit is reached the rest are recompiled on every request with no warning anywhere. `opcache.memory_consumption` defaults to 128 MB, which a large site can fill; when it fills, OPcache flushes everything and starts again, which shows up as a periodic spike in response time that looks like a host problem and is not. Both are worth setting even on a small plan, and the cost is a fixed block of memory shared by all workers rather than per worker.

#### The database's share

A WordPress database is usually much smaller than people expect. A blog with a thousand posts is tens of megabytes. Where it grows is in three places: post revisions, `wp_options` rows marked to load on every request, and for shops, orders and their metadata.

The autoloaded options table is the one that silently taxes every single request, because WordPress loads all of it into PHP memory before anything else happens. Check it:

```sql
SELECT ROUND(SUM(LENGTH(option_value)) / 1024) AS kb, COUNT(*) AS n
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto');
```

Change the table prefix to match your install. WordPress 6.6 added extra autoload values, which is why the query checks three of them rather than one. Under about 1 MB is healthy. Several megabytes means a plugin, usually one you uninstalled months ago, is leaving data behind, and every request on the site pays for it in both time and memory.

If you run your own database server, its cache of table pages is the setting that matters and everything else is noise: give InnoDB's buffer pool enough room to hold the working set of your tables, and watch that the server as a whole still fits inside the plan. On a managed plan you do not tune this, which is both a limitation and a relief. RE:NODE's web plans include database slots created from the panel with a generated host, user and password, and an "Open in phpMyAdmin" button that signs in with a single-use token expiring in sixty seconds - enough to run the query above and export a dump, not enough to be a permanent workflow. [Importing and exporting through phpMyAdmin](https://www.renode.space/blog/phpmyadmin-import-export-guide) covers the limits you will hit on a large database.

A separate database plan is worth it once the site is large enough that the database and PHP are fighting each other for the same memory, which in practice is a shop with tens of thousands of orders or a site doing heavy reporting. [Connection pools and limits](https://www.renode.space/blog/connection-pools-and-limits) is the other half of that decision.

#### Caching changes the arithmetic completely

Every number above assumes PHP runs. A full-page cache means it does not, and that single change is worth more than any plan upgrade you can buy.

The layers, in order of how much they change:

1. **Page cache.** The rendered HTML is stored and served without touching PHP or the database. A cached page costs single-digit milliseconds and no worker. This is the difference between needing four workers and needing forty.
2. **OPcache.** Compiled bytecode, covered above. Always on, always worth it.
3. **Object cache.** Persistent storage for the results WordPress currently recomputes each request. Requires Redis or Memcached to be available, which on most shared PHP hosting it is not - and without it, WordPress falls back to a per-request cache that is thrown away at the end of every page load. [Redis, and when you actually need it](https://www.renode.space/blog/redis-when-you-need-it) covers when it is worth arranging.
4. **Browser and CDN caching.** Headers, so that repeat visitors and edge nodes do not ask at all. [HTTP caching headers explained](https://www.renode.space/blog/http-caching-headers-explained) is the reference.

The catch, and it is a large one: page caching only helps anonymous visitors. Logged-in users, carts, checkouts, forums, member dashboards and anything personalised all bypass the cache by design, because the alternative is showing one person another person's basket. That is why a membership site or a shop needs real worker capacity in a way that a blog with the same traffic does not, and why the table at the top of this post separates them. [WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching) goes through which plugin does what.

> [!TIP]
> Before buying more memory, check your cache hit ratio. A site where 90% of requests are served from the page cache and one where 40% are need very different plans for the same visitor count, and the gap is usually a cookie or a query string defeating the cache rather than anything fundamental.

#### Measuring your own site

Five minutes of measurement beats any table, including the one above.

Worker size first. On a box where you have a shell, this prints resident memory per PHP process, largest first:

```bash
$ ps -eo rss,comm --sort=-rss | grep php-fpm | head -15
$ ps -eo rss,comm | grep php-fpm | awk '{s+=$1; n++} END {print s/n/1024 " MB avg"}'
```

Resident memory overcounts a little, because workers share the pages of the parent process and of OPcache, so treat the average as a safe upper bound rather than an exact figure. Then turn on the FPM status page and watch what the pool is actually doing:

pool.d/www.conf:

```ini
pm.status_path = /status
```

`listen queue` above zero means requests are waiting for a worker. `max children reached` above zero means you have run out at least once since the last restart. Those two counters answer the "do I need a bigger plan" question directly, and nothing else does.

From inside WordPress, WP-CLI gives you the rest:

```bash
$ wp db size --tables --human-readable
$ wp plugin list --status=active --format=count
$ wp option get cron --format=json | head -c 400
$ wp transient delete --expired
```

Install Query Monitor on a staging copy and load your slowest page while logged in as an administrator. It prints peak memory for that request, the number of database queries, the slowest queries, and any HTTP requests the page made to external services. That last panel is where the real surprises live: a plugin calling a licence server on every page load holds a worker for as long as that server takes to answer, and a worker held for two seconds is a worker not serving anybody.

On a panel-based host the equivalent of the first half is the console graphs, which plot memory, CPU and disk against the plan's limits. Web-server request noise is folded behind a count you can switch off, so the log stays readable while a load test is running.

#### When to buy more, and when to fix the site

Buy memory when the measurements say the workers are correctly sized and there are simply not enough of them: the FPM status page shows the queue growing at peak, average worker size is reasonable, and cached pages are fast. That is a real capacity limit and more memory genuinely fixes it.

Do not buy memory for any of these, because it will not help:

- **Slow pages with no traffic.** One request taking three seconds on an idle server is a code problem. More memory makes it a three-second request with more memory.
- **A worker average above 200 MB on a normal site.** Something is loading far too much. Deactivate plugins in halves on a staging copy until the average drops.
- **Time-outs at exactly 30 or 60 seconds.** That is `max_execution_time` or `request_terminate_timeout`, not memory.
- **Admin-ajax hammering.** A heartbeat or a plugin polling every few seconds consumes workers with no visitors at all. Throttle it.
- **WordPress cron.** By default `wp-cron.php` fires on page loads, so a busy moment also becomes a scheduled-task moment. Set `DISABLE_WP_CRON` and run it from a real scheduler instead - on RE:NODE that is the Schedules tab with a cron expression and ordered tasks.

One more honest note: WordPress is installed by you, not by us. There is no one-click installer on RE:NODE, and there is no marketplace of pre-baked stacks. What you get is a container with the runtime, SFTP and a file manager, database slots, a proxy slot that issues and renews the certificate once you point an A record at the address shown, and backup slots. [Installing WordPress manually](https://www.renode.space/blog/install-wordpress-manually) is the fifteen minutes that covers, and [migrating WordPress to a new host](https://www.renode.space/blog/migrate-wordpress-to-a-new-host) is the version for a site that already exists.

If the site sits behind that proxy slot, remember that PHP sees the proxy's address as the client address unless you tell it otherwise. The real visitor IP arrives in the `X-Forwarded-For` header, and security plugins, comment logging and rate limiting all read the wrong value until WordPress is configured to trust it. It is the single most common surprise after a move.

#### FAQ

##### Is 1 GB enough for WordPress?

For a blog or a brochure site with page caching on, yes, comfortably. It gives you room for the operating system, a small database, OPcache and four to six PHP workers, which is more concurrency than most small sites ever use. It is not enough for WooCommerce, a membership site, or a page builder with forty plugins.

##### What should I set memory_limit to?

256M for a normal site. It is a per-request ceiling, not an allocation, so a higher value costs nothing until something actually uses it - but it also multiplies by your worker count in the worst case. If a page genuinely needs more than 256M to render, fix the page rather than raising the number.

##### Why does my site go down at peak but the memory graph looks fine?

Because you ran out of PHP workers, not memory. Requests queue behind the busy workers and eventually time out as 502 or 504 errors while total memory sits well under the limit. Check `max children reached` on the FPM status page, and either raise `pm.max_children` if there is memory spare or make requests finish faster.

##### Does WooCommerce really need that much more?

Yes, for a structural reason rather than a code-quality one. Carts, checkouts, account pages and admin order screens cannot be page-cached, so every one of them runs PHP and hits the database. A shop with the same visitor count as a blog does several times the real work. [WooCommerce hosting requirements](https://www.renode.space/blog/woocommerce-hosting-requirements) goes through the specifics.

##### Will more RAM make my site faster?

Only if you are currently running out of it. Memory buys concurrency, not speed. A single page load takes the same time on 1 GB and on 8 GB if nothing is contended. What makes a page faster is caching, fewer plugins, fewer database queries and a faster CPU core - see [TTFB, Core Web Vitals and hosting](https://www.renode.space/blog/ttfb-core-web-vitals-and-hosting) for what hosting can and cannot change.

##### How many visitors can 2 GB handle?

With page caching and a normal theme, tens of thousands of page views a day without noticing. Without caching, and with a heavy builder theme, the same plan might struggle at a few hundred concurrent readers. The honest answer is that the plan size is not the variable that decides it - cache hit ratio and average request time are.

RE:NODE web plans run on NVMe in Germany with database slots, backup slots, SFTP and an in-browser file editor, plus a proxy slot that issues and renews the certificate automatically once your A record points at the address shown. WordPress itself is yours to install, and the console graphs show memory, CPU and disk against the plan's limits so you can see exactly which one you are near.

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

---

### HTTP caching headers: Cache-Control and ETag

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/http-caching-headers-explained

Almost every site needs the same two rules. Files with a content hash in the name - `app.9f2c1b.js` - get `Cache-Control: public, max-age=31536000, immutable`, because the name changes when the content does and the browser never needs to ask again. HTML gets `Cache-Control: no-cache`, which stores the page but revalidates it, so a deploy is visible immediately and a repeat visit still costs one cheap 304 instead of a full download.

That is ninety per cent of web caching. The rest of this post is the other ten per cent: what each directive actually means, why your headers are being ignored, and what a CDN does differently from a browser.

#### The caches between your server and the visitor

A response can be stored in more places than most people picture, and each one obeys slightly different rules.

```diagram
title: Where a response can be stored on its way back
browser = Browser cache | one visitor, on disk
cdn = CDN or shared cache | many visitors
proxy = Reverse proxy | TLS and routing
origin = Your server | sets the headers
browser -> cdn : request
cdn -> proxy : only on a miss
proxy -> origin : forwarded
origin --> browser : Cache-Control decides
```

The distinction that matters is **private** versus **shared**. The browser cache belongs to one person, so it may hold their account page. A CDN is shared by everybody, so it must not - and the `private` directive is how you say so. Getting this backwards is how one customer's order confirmation ends up served to another, which is a bad day that starts with a header.

Your reverse proxy sits in between and normally does not cache at all unless you configure it to. On RE:NODE the proxy slot terminates TLS and forwards to your server; caching decisions stay with your application and with whatever CDN you put in front.

#### Cache-Control, directive by directive

`Cache-Control` is a response header carrying a comma-separated list. These are the directives that earn their place:

| Directive | Applies to | What it means |
| --- | --- | --- |
| `max-age=600` | All caches | Fresh for 600 seconds from now |
| `s-maxage=600` | Shared caches only | Overrides `max-age` for CDNs and proxies |
| `public` | All caches | May be stored even if the request was authenticated |
| `private` | Browser only | A shared cache must not store it |
| `no-cache` | All caches | Store it, but revalidate before every reuse |
| `no-store` | All caches | Do not write it down at all |
| `must-revalidate` | All caches | Once stale, never serve it without checking |
| `immutable` | Browser | Do not revalidate, even on a reload |
| `stale-while-revalidate=60` | All caches | Serve stale for 60s while fetching fresh |
| `stale-if-error=86400` | All caches | Serve stale if the origin is failing |

Two of those are misread constantly. **`no-cache` does not mean do not cache.** It means cache it and always ask first, which is exactly what you want for HTML: the round trip is tiny when nothing changed. `no-store` is the one that means do not keep a copy, and it is for responses that must never touch a disk - a bank statement, a one-time token, a password reset page.

`immutable` is the directive that makes hashed assets genuinely free on a repeat visit. Without it, a browser reload revalidates every cached file, and you pay a round trip per asset to be told nothing changed. With it, the browser skips the check entirely for the lifetime of the `max-age`. It is only safe on a URL whose content can never change, which is precisely what a content hash guarantees.

`stale-while-revalidate` is the cheapest performance win most sites never switch on. `max-age=60, stale-while-revalidate=600` means a cached page is served instantly for eleven minutes at worst, with the refresh happening in the background rather than in front of the visitor.

If you send no caching headers at all, caches are allowed to guess. RFC 9111 permits heuristic freshness based on `Last-Modified` - commonly ten per cent of the time since the document last changed - so a file modified a year ago may be cached for weeks by something in the path. Silence is not the same as "do not cache".

#### ETag, Last-Modified and the 304

Freshness says how long a cache may reuse a response without asking. Validation is what happens when it does ask.

- `Last-Modified: Wed, 10 Sep 2026 09:41:12 GMT` - a timestamp with one-second resolution.
- `ETag: "6f9a2c-1b4d"` - an opaque token that changes when the content does. `W/"..."` marks a weak tag, meaning semantically equivalent rather than byte-identical.

On the next request the browser sends `If-Modified-Since` or `If-None-Match` with the value it holds. If nothing changed, the server answers `304 Not Modified` with headers and no body, and the browser uses the copy it already has.

```bash
$ curl -sI https://example.com/style.css | grep -i -E "etag|cache-control|last-modified"
$ curl -sI -H 'If-None-Match: "6f9a2c-1b4d"' https://example.com/style.css | head -1
HTTP/2 304
```

A 304 saves the bytes but not the round trip. On a connection with 120 ms of latency, forty assets that all return 304 still cost you a slow-feeling page. That is the entire argument for long `max-age` with `immutable` on fingerprinted files: the fastest request is the one that never happens.

nginx generates `ETag` for static files automatically from the modification time and the size, which is stable across servers as long as the files have the same timestamps. A deploy that rewrites every file with a new mtime invalidates every ETag on the site even where the content is identical, so use a copy method that preserves timestamps when it matters. Application frameworks usually hash the response body instead, which is correct but costs CPU on every request.

#### The two-tier strategy

Almost every site should end up here:

| What | Cache-Control | Why |
| --- | --- | --- |
| HTML pages | `public, no-cache` | Always fresh, cheap 304 on repeat |
| Hashed CSS, JS, fonts | `public, max-age=31536000, immutable` | The name changes when the content does |
| Unhashed CSS and JS | `public, max-age=3600` | You cannot prove it is unchanged |
| Uploaded images | `public, max-age=2592000` | Rarely replaced in place |
| Logged-in pages | `private, no-store` | Never in a shared cache |
| API responses | `private, max-age=0` or `no-store` | Decide per endpoint, do not guess |

A year - 31536000 seconds - is the conventional maximum for `max-age`, and there is nothing to gain from a larger number. If your build already writes hashed filenames, as every modern bundler does, the top two rows cover your whole site; [static site hosting](https://www.renode.space/blog/static-site-hosting-guide) has the nginx blocks in context.

The tier that causes trouble is the third: a stylesheet at a fixed URL that you edit in place. There is no safe long cache for it, because some visitors will hold the old copy for as long as you told them to. The fix is not a shorter cache, it is a different URL. Every build tool can fingerprint output; if you are hand-writing HTML, append a version query - `style.css?v=7` - and change it when you edit. Query strings are part of the cache key for browsers and for most CDNs, though some CDNs can be configured to ignore them, which turns your cache buster into a no-op.

> [!WARNING]
> Never put a long `max-age` on an HTML page you might need to change. There is no way to reach into a browser cache and fix it. A CDN you can purge; a million browsers you cannot, and you will be waiting out the expiry you chose.

#### Vary, cookies and the headers that disable caching

`Vary` tells caches which request headers change the response. It is necessary and dangerous in equal measure, because every value multiplies the number of copies a cache has to keep.

- `Vary: Accept-Encoding` - correct and required when you compress. Without it a cache can hand a gzipped body to a client that did not ask for one.
- `Vary: Cookie` - technically correct on a page that differs per user, and in practice it means no shared cache ever gets a hit, because every visitor has a different cookie.
- `Vary: User-Agent` - shatters the cache into thousands of variants. Avoid it.

Cookies are the other silent cache killer. A response carrying `Set-Cookie` cannot be stored by a shared cache for reuse by other people, and most CDNs simply bypass the cache when they see one. That is the right default, and it is why an analytics or consent script that sets a cookie server-side on every page can halve your cache hit rate without anybody noticing.

PHP does this to you by default. The first call to `session_start()` sends, for the whole response:

```text
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
```

That is `session.cache_limiter` doing its job, and it is correct for a page showing somebody's account. It is wrong for a public page that happens to start a session for no reason. Either do not start the session on pages that do not need one, or set `session_cache_limiter('public')` before `session_start()` and take responsibility for what you are doing.

WordPress and most CMS page caches follow the same logic: anything with a login cookie bypasses the cache entirely, which is why an admin testing the site sees the slow path and reports that caching is not working. Test in a private window. [WordPress speed and caching](https://www.renode.space/blog/wordpress-speed-and-caching) goes through the layers in that specific case.

#### CDNs and shared caches

A CDN is a shared cache in many locations. It reads the same headers, with two additions worth knowing.

`s-maxage` applies only to shared caches, which lets you keep HTML fresh in browsers and cached at the edge: `Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=3600`. Browsers revalidate every time, the CDN serves from its own copy for ten minutes and refreshes in the background, and your origin sees a fraction of the traffic. For a site whose pages are the same for everyone, this is the single biggest change you can make.

`CDN-Cache-Control` is a targeted header that only CDNs read, so you can give them different instructions without touching what browsers see. Older infrastructure uses `Surrogate-Control` for the same purpose. Support varies, so check your provider's documentation before relying on it - [Cloudflare for websites and game servers](https://www.renode.space/blog/cloudflare-for-websites-and-game-servers) covers what the common one does with these.

Two operational habits go with a CDN. First, know how to purge, by URL and by tag, and put the purge in your deploy script rather than in a runbook nobody opens. Second, read the cache status header your provider sends - `HIT`, `MISS`, `EXPIRED`, `BYPASS` - because it turns "is caching working" from an argument into a fact. If you are seeing `BYPASS`, look for a cookie.

A CDN does not fix a slow origin for uncached requests. The first visitor to each page, in each location, still waits for your server, and so does every request a cookie makes uncacheable. [TTFB, Core Web Vitals and hosting](https://www.renode.space/blog/ttfb-core-web-vitals-and-hosting) covers which part of the wait is yours to fix.

#### Setting the headers

On nginx, static assets by extension and HTML separately:

```nginx
location ~* \.(?:css|js|woff2|avif|webp|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    access_log off;
}

location ~* \.html$ {
    add_header Cache-Control "public, no-cache" always;
}
```

`expires 1y;` sets both the legacy `Expires` header and a matching `max-age`, which is why the explicit `add_header` follows it: you want the extra directives.

The nginx trap that catches everyone: `add_header` directives are inherited from the enclosing block **only if the current block defines none of its own**. Add a single `add_header` inside a `location` and every header set in the parent `server` block disappears from those responses - including your security headers. If you use `add_header` in a location, repeat everything the parent set. The `always` flag makes the header apply to error responses too, not only 2xx and 3xx.

From PHP, set it before any output, and remember that output buffering settings decide how early "before any output" really is - [php.ini settings that matter](https://www.renode.space/blog/php-ini-settings-that-matter) covers the ones that bite:

```text
header('Cache-Control: public, max-age=300, stale-while-revalidate=600');
```

From an application framework, use its response helpers rather than raw headers, because the framework may add its own later and the last one written wins.

#### Checking what you actually send

Never trust the configuration; read the wire. One command tells you most of it:

```bash
$ curl -sI https://example.com/ | grep -i -E "cache-control|etag|age|vary|set-cookie"
```

What to look for, in order:

1. **`Set-Cookie` on a public page.** Almost always an accident, and it disables shared caching.
2. **`Age`.** Present means something in front of you served a stored copy, and its value is how old that copy is.
3. **`Vary`.** Anything beyond `Accept-Encoding` needs a reason.
4. **Conflicting directives.** `no-cache` together with `max-age=3600` is legal and confusing; `no-store` beats everything.
5. **The browser devtools network panel.** The size column showing "disk cache" or "memory cache" with no network time is what a working cache looks like. Remember that a hard reload bypasses the cache and tells you nothing about the real experience.

When a change to headers appears to do nothing, check in this order: is a CDN in front serving an old copy, is your browser holding a copy from before the change, and is a `location` block further down the configuration overriding you. It is one of those three about eighty per cent of the time.

#### FAQ

##### What is the difference between no-cache and no-store?

`no-cache` stores the response and revalidates it before every reuse, so you still get a 304 and a fast page. `no-store` forbids writing it down anywhere. Use `no-cache` for HTML that changes, and `no-store` only for genuinely sensitive responses - it also disables the browser's back/forward cache, which makes navigation feel slower.

##### Should I use ETag or Last-Modified?

Both, if they are free. `Last-Modified` has one-second resolution and is fine for files; `ETag` is exact and works for generated responses. Serving both lets the client use whichever it prefers. Do not disable ETags unless you have a specific multi-server problem that you have measured.

##### Why is my CSS still the old version after a deploy?

Because you told browsers to keep it. If the URL did not change and you sent a long `max-age`, the only cure is time or a new URL. Fingerprint your assets so this cannot happen again, and keep the long cache for files whose names encode their content.

##### Does caching help a site with very little traffic?

It helps repeat visitors and it helps every page load after the first, which is most of what a visitor experiences. It does not help the first request on a cold cache. For a small site the bigger win is usually the origin itself being quick, plus compression.

##### How long should images be cached?

Thirty days is a reasonable default for uploaded media, a year if the filename includes a hash. The failure mode is replacing an image in place and having the old one persist, so prefer uploading a new file with a new name to overwriting one.

##### Do these headers affect search rankings?

Not directly. They affect how fast pages load for real visitors, which is measured in the field and does feed into ranking signals through Core Web Vitals. Caching is worth doing for the users; the ranking is a side effect.

RE:NODE web plans run nginx with your files in your hands: a full file manager and SFTP for the site itself, NVMe storage underneath, and a proxy slot that terminates TLS and passes the real client address through in `X-Forwarded-For`, so the caching decisions stay yours.

[See web hosting plans](https://www.renode.space/web-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.
