# Dedicated Servers - everything RE:NODE has published

> Full root access on real hardware, provisioned by hand and delivered with credentials inside a day.

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

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

## What it is

Real hardware, root from the first minute, and credentials delivered by email within a day. For the workloads that should not share a kernel with anything else.

### Provisioned by a person

These are not spun up by a script the second the payment clears, and we do not pretend otherwise: the order is reviewed, the machine prepared, and the SSH details sent to you within 24 hours.

### Root means root

Your kernel, your firewall, your packages. Install a control panel, run containers, host whatever mix of things you like.

### Honest specifications

The tiers say what the machine has. There is no burst figure quoted as though it were sustained and no oversubscription hidden behind an asterisk.

### Questions about this service

**How long does delivery take?**

Within 24 hours of payment, usually much less. You are emailed the address, the root credentials and how to reach us if anything is wrong.

**Can I run game servers on it?**

Yes. If you would rather not administer them yourself, the game hosting plans give you the same games with the panel already set up.

### 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/dedicated-servers or in the panel shop at https://panel.renode.space before quoting one.

### intel i9 9900k

A dedicated machine with full root access, prepared by hand and delivered with credentials within a day.

Page: https://www.renode.space/dedicated-servers#intel-i9-9900k

| Plan | Price / month | Memory | CPU | NVMe disk | Databases | Backups | Ports |
|---|---|---|---|---|---|---|---|
| Standard Computer | $17 | 8 GB | 2 vCPU | 128 GB | 1 | 1 | 1 |
| Advanced Computer | $27 | 16 GB | 4 vCPU | 250 GB | 1 | 1 | 1 |
| Premium Computer | $37 | 24 GB | 6 vCPU | 500 GB | 1 | 1 | 1 |


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

## Tutorials

15 posts, newest first. Each one follows in full after the list.

- [SSH config file and ProxyJump: hosts, keys and jump hosts](https://www.renode.space/blog/ssh-config-file-and-jump-hosts) (2026-10-15): Write a ~/.ssh/config that turns long ssh commands into short names, picks the right key, jumps through a bastion and reuses connections - on Linux, macOS and Windows.
- [Choosing a hosting provider: what to look at, from the inside](https://www.renode.space/blog/choosing-a-hosting-provider-from-the-inside) (2026-10-14): What matters when picking a game server, VDS or app host, from the operator's side: CPU, 'unlimited', status pages, backups, access, support and red flags.
- [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) (2026-09-21): Run a proxy, an app and a database from one compose file: networks, volumes, env files, healthchecks, updates and rollbacks on a single server.
- [Docker on a VDS: images, volumes and ports](https://www.renode.space/blog/docker-on-a-vds-getting-started) (2026-09-21): Install Docker on a Linux server and run something properly: images and tags, published ports, volumes that survive, restart policies, limits and log rotation.
- [Fail2ban on a VDS: jails, bans and testing](https://www.renode.space/blog/fail2ban-guide) (2026-09-21): Install fail2ban, write a jail.local that survives upgrades, tune bantime and findtime, add nginx jails, and verify the bans are real rather than theoretical.
- [First hour on a new VDS: a setup checklist](https://www.renode.space/blog/first-hour-on-a-new-vds) (2026-09-21): What to do on a fresh VDS: updates, a sudo user, SSH keys, a default-deny firewall, time, swap and automatic security updates, in the right order.
- [Linux commands for server admins: top 40](https://www.renode.space/blog/linux-commands-for-server-admins) (2026-09-21): The commands that cover most of running a VDS: files, logs, processes, memory, disk, systemd, permissions and networking, with the flags worth memorising.
- [Linux swap and the OOM killer: what to set](https://www.renode.space/blog/linux-swap-and-the-oom-killer) (2026-09-21): What swap is really for, how much to give a VDS, what swappiness changes, how the OOM killer picks its victim, and how to read the kernel log it leaves behind.
- [Several game servers on one VDS](https://www.renode.space/blog/multiple-game-servers-on-one-vds) (2026-09-21): How to run four or five game servers on one box: a port map, one user each, systemd units with limits, LinuxGSM, staggered updates and backups, and when not to bother.
- [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.
- [SSH keys and sshd_config hardening](https://www.renode.space/blog/ssh-keys-and-hardening) (2026-09-21): Generate an ed25519 key, install it safely, use an agent and a jump host, and harden sshd_config without ever locking yourself out of the server.
- [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) (2026-09-21): Turn an app into a service that survives reboots and crashes: unit files, Type and Restart, environment files, journalctl, hardening, template units and timers.
- [UFW firewall: default deny, ports and Docker](https://www.renode.space/blog/ufw-firewall-guide) (2026-09-21): Set up UFW on a Linux server without locking yourself out: default policies, allow rules, UDP ranges for game servers, rate limits and the Docker trap.
- [VPS, VDS or dedicated server: what they mean](https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server) (2026-09-21): One is a marketing word, one is a promise about CPU, and one is a physical machine. How to tell which you are buying and how to verify it afterwards.
- [VDS or a game panel: which do you need](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) (2026-07-14): Root access buys flexibility and costs hours. What a panel does for you, what a VDS makes you do yourself, and the honest cost of each.

---

### SSH config file and ProxyJump: hosts, keys and jump hosts

Published 2026-10-15 by RE:NODE Team. https://www.renode.space/blog/ssh-config-file-and-jump-hosts

The client-side SSH config file is `~/.ssh/config` (on Windows, `C:\Users\<you>\.ssh\config`). Each `Host` block gives a short name to a connection and stores everything you would otherwise type: `HostName` for the real address, `User`, `Port`, `IdentityFile` for the key, `ProxyJump` for the machine in between. After that, `ssh web` replaces `ssh -i ~/.ssh/id_work -p 2222 deploy@203.0.113.10`, and so do `scp`, `sftp`, `rsync`, Git and every editor that speaks SSH, because they all read the same file.

The one rule that explains nearly every surprise: **for each setting, the first value ssh finds wins**. Specific blocks go at the top, the catch-all `Host *` goes at the bottom, and a setting given on the command line beats anything in the file. Keep that in mind and the rest of this guide is a reference you can copy from.

This post is about the client side. Generating keys, the agent and hardening `sshd_config` on the server are covered in [SSH keys and sshd_config hardening](https://www.renode.space/blog/ssh-keys-and-hardening); read that first if you do not yet have a key.

#### Where the config file lives

There are two files, read in order: your own, then the system-wide one.

| System | User file | System file |
| --- | --- | --- |
| Linux, BSD | `~/.ssh/config` | `/etc/ssh/ssh_config` |
| macOS | `~/.ssh/config` | `/etc/ssh/ssh_config` |
| Windows OpenSSH | `%USERPROFILE%\.ssh\config` | `%ProgramData%\ssh\ssh_config` |

The file does not exist until you create it. On Linux and macOS it must not be writable by anybody but you, or ssh refuses to use it with `Bad owner or permissions on /home/you/.ssh/config`:

```bash
$ mkdir -p ~/.ssh && chmod 700 ~/.ssh
$ touch ~/.ssh/config && chmod 600 ~/.ssh/config
```

Note the name: `ssh_config` is the client, `sshd_config` (with the d, for daemon) is the server. Editing the wrong one is a surprisingly common way to lose an afternoon, and the server file lives in the same `/etc/ssh` directory.

Two flags make the file transparent. `ssh -G name` prints the fully resolved configuration for a name without connecting - every option, after every block and default has been applied. `ssh -F /dev/null name` connects while ignoring your config, which tells you in one step whether the file is the problem. `-G` arrived in OpenSSH 6.8, so it is available on anything you are likely to meet.

```bash
$ ssh -G web | grep -E '^(hostname|user|port|identityfile|proxyjump) '
hostname 203.0.113.10
user deploy
port 2222
identityfile ~/.ssh/id_work
proxyjump none
```

#### Host blocks and how ssh picks a value

A `Host` line starts a block that runs until the next `Host` or `Match` line. Indentation is for you, not for ssh. Keywords are case-insensitive, values mostly are not, and you may write `Keyword value` or `Keyword=value`.

~/.ssh/config:

```text
Host web
    HostName 203.0.113.10
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_work
    IdentitiesOnly yes

Host *.lab
    User admin
    StrictHostKeyChecking accept-new

Host *
    ServerAliveInterval 30
    AddKeysToAgent yes
```

When you type `ssh web`, ssh walks the file from top to bottom. Every block whose pattern matches contributes the settings it has not already seen. So `web` gets its port from its own block and `ServerAliveInterval` from `Host *`. If `Host *` were at the top with `User root` in it, every later `User` line would be ignored, which is the classic version of "my config is being ignored".

The exception is the small set of options that accumulate instead of taking the first value: `IdentityFile`, `CertificateFile`, `LocalForward`, `RemoteForward`, `DynamicForward` and `SendEnv`. Several `IdentityFile` lines from several matching blocks produce a list, tried in order.

Patterns are matched against the name you typed, not against `HostName`. They support `*` and `?`, several patterns on one line, and negation with `!`:

~/.ssh/config:

```text
Host web db cache
    User deploy

Host 10.0.0.* !10.0.0.1
    ProxyJump bastion

Host * !github.com
    ForwardAgent no
```

A negated pattern on its own never matches anything; it only excludes from the other patterns on the same line. `Host !github.com` alone is a block that applies to nothing.

##### Match, for conditions Host cannot express

`Match` blocks test conditions rather than names. The useful ones are `host` (the name after `HostName` has been applied), `originalhost` (what you typed), `user`, `localuser`, `exec` and `all`.

~/.ssh/config:

```text
# Use the office jump host only when not on the office network
Match host 10.20.* !exec "ip route | grep -q 10.20.0.0/16"
    ProxyJump gw.example.com
```

`exec` runs a shell command and matches if it exits with status zero. It runs on every connection, so keep it fast, and remember it is a shell command run on your machine - never paste one you do not understand.

#### The settings worth knowing

These are the keywords that earn a place in a real config. Defaults are the OpenSSH defaults.

| Keyword | Default | What it does |
| --- | --- | --- |
| `HostName` | the name typed | Real address or DNS name |
| `User` | your local username | Remote account |
| `Port` | `22` | Remote SSH port |
| `IdentityFile` | the standard `id_*` files | Private key to offer |
| `IdentitiesOnly` | `no` | Offer only the named keys |
| `ProxyJump` | none | Hop through these hosts first |
| `ServerAliveInterval` | `0` (off) | Seconds between keepalives |
| `ServerAliveCountMax` | `3` | Missed keepalives before giving up |
| `ConnectTimeout` | system TCP timeout | Seconds to wait for a connect |
| `StrictHostKeyChecking` | `ask` | How to treat unknown host keys |

`HostName` takes tokens, so one block can serve a whole family of machines. `%h` is the name you typed:

~/.ssh/config:

```text
Host *.prod
    HostName %h.example.com
```

That does not quite do what people expect: `ssh api.prod` connects to `api.prod.example.com`. If your real names do not contain `.prod`, write one block per host instead. Clever patterns save lines and cost clarity, and the config is a file you will read at two in the morning.

`User` is worth setting on every block, because the default is your local login name. On a Windows laptop that is often a capitalised name, or an email-shaped one, that does not exist on any Linux server, and the error you get is a plain `Permission denied` with nothing pointing at the username.

`Port` matters mostly for machines that moved SSH away from 22 to quiet the logs, and for anything that runs its own SSH-speaking service on another port. `ssh -p 2222` on the command line wins over the file, as every command-line option does.

##### IdentityFile and IdentitiesOnly

`IdentityFile` names a private key, and the path may start with `~`. Without one, ssh tries the standard names in `~/.ssh` - `id_rsa`, `id_ecdsa`, `id_ed25519` and their security-key variants - plus anything loaded in the agent.

That default is fine with one key. With several it causes the failure everyone with a work and a personal key eventually meets: ssh offers every key in the agent, one after another, each offer counts as an authentication attempt, and a server with `MaxAuthTries 3` disconnects you with `Too many authentication failures` before it reaches the right one. `IdentitiesOnly yes` tells ssh to offer only the keys named for that host, even when the agent holds more. Put it in `Host *` near the bottom and name a key in every block that needs one.

`IdentityFile` also accepts the `.pub` file when the private half lives somewhere else, such as a password manager acting as the agent. ssh uses the public key to pick which agent key to offer, which is the cleanest way to combine `IdentitiesOnly` with an agent that holds a dozen keys.

##### Host key checking without the yes-yes-yes habit

`StrictHostKeyChecking accept-new` (OpenSSH 7.6 and later) adds keys for hosts you have never seen without asking, and still refuses loudly when a known host's key changes. That is a sensible setting for a lab of throwaway machines and a poor one for a production server you are reaching for the first time, where the prompt is the only moment you can compare the fingerprint. Scope it with a pattern rather than putting it in `Host *`. `UserKnownHostsFile` can point a group of hosts at a separate file, useful for a test network that is rebuilt every week:

~/.ssh/config:

```text
Host *.lab
    StrictHostKeyChecking accept-new
    UserKnownHostsFile ~/.ssh/known_hosts_lab
```

#### ProxyJump and jump hosts

A jump host (also called a bastion) is one machine you can reach from the internet, standing in front of others that you cannot: a database on a private network, a game server behind a firewall that only allows SSH from the bastion, or every internal machine of a small company. `ProxyJump` makes the hop invisible.

~/.ssh/config:

```text
Host bastion
    HostName 203.0.113.10
    User jump
    IdentityFile ~/.ssh/id_ed25519

Host db
    HostName 10.0.0.20
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    ProxyJump bastion
```

`ssh db` now connects to `bastion`, asks it to open a TCP connection to `10.0.0.20:22`, and runs a second, complete SSH session through that tunnel. The important property is where authentication happens: on your laptop, end to end, for both hops. The bastion forwards encrypted bytes and never sees your private key, your agent or your session. That is why `ProxyJump` replaced agent forwarding as the right way to reach a second machine.

```diagram
title: ProxyJump keeps both logins on your machine
laptop = Your machine | holds the keys
bastion = bastion | public, port 22
db = db | 10.0.0.20, private
laptop -> bastion : SSH session 1
bastion -> db : TCP forward only
laptop --> db : SSH session 2, inside 1
```

Things worth knowing about it:

- **It arrived in OpenSSH 7.3.** On anything older, the equivalent is `ProxyCommand ssh -W %h:%p bastion`, which does the same job and is what most pre-2017 tutorials show. Do not set both on one host; whichever ssh reads first wins.
- **Every hop is looked up in the config.** `ProxyJump bastion` uses the `bastion` block's address, user, port and key. You can also write a hop inline as `user@host:port`.
- **Chains are comma-separated.** `ProxyJump bastion,inner` goes through both, in order, each hop again resolved from the config.
- **On the command line it is `-J`.** `ssh -J jump@203.0.113.10 deploy@10.0.0.20` is the one-off form, and `scp` and `sftp` accept `-J` too.
- **`ProxyJump none`** in a more specific block switches off a jump inherited from a broad pattern further down.

The target sees the connection arrive from the bastion's private address, so firewall rules on the inner machine should allow SSH from that address and nothing else. On the bastion itself, `sshd` must permit TCP forwarding: `AllowTcpForwarding no` or `DisableForwarding yes` in its `sshd_config` breaks every jump with a `channel 0: open failed: administratively prohibited` message.

##### A bastion account that can only jump

A bastion is more useful when the account on it can do nothing except forward. In the jump user's `authorized_keys` on the bastion, the key options from the [hardening guide](https://www.renode.space/blog/ssh-keys-and-hardening) do exactly that:

/home/jump/.ssh/authorized_keys (on the bastion):

```text
restrict,port-forwarding,permitopen="10.0.0.20:22",permitopen="10.0.0.21:22" ssh-ed25519 AAAAC3Nza... laptop
```

`restrict` turns off everything optional, `port-forwarding` turns forwarding back on, and `permitopen` limits it to the listed destinations. With that key, `ssh bastion` alone gets no shell, while `ssh db` works. A stolen key opens two SSH ports on two private machines, which still need their own keys.

#### Keepalives and connections that freeze

A session that hangs rather than closing, after a few minutes of inactivity, is almost always a NAT table entry in a home router, a mobile network or a corporate firewall that was quietly dropped. The fix is on the client:

~/.ssh/config:

```text
Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3
```

Every 30 seconds of silence, ssh sends a request inside the encrypted channel and expects an answer. The traffic keeps middleboxes from forgetting the connection, and if three answers in a row go missing, ssh closes the session after about 90 seconds instead of leaving a frozen terminal. Do not confuse it with `TCPKeepAlive`, which is on by default and works at the TCP level; it is easier to spoof and its timing is the operating system's, often two hours, which is no help at all against a router that forgets after five minutes.

`ConnectTimeout 10` is the companion setting for scripts. Without it, a connection to a machine that is down waits for the operating system's TCP timeout, which can be well over a minute, and a backup script that loops over hosts stalls on the first dead one.

#### Connection reuse with ControlMaster

Multiplexing lets later sessions to the same host ride on the first one's TCP connection. The second `ssh web`, every `scp`, and each step of an Ansible run or a `git pull` start instantly and do not authenticate again. Over a slow link or through a jump host, the difference is seconds per command.

~/.ssh/config:

```text
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 10m
```

`ControlMaster auto` makes the first connection the master and lets later ones use it. `ControlPath` is the local socket file; `%C` is a hash of the connection details (local host, remote host, port, user), so it is unique per destination and always short. That matters: Unix sockets have a path limit of roughly a hundred characters, and the older `%r@%h:%p` style fails with long hostnames. `ControlPersist 10m` keeps the master open for ten minutes after the last session closes, in the background.

Manage the master with `-O`:

```bash
$ ssh -O check web     # is a master running for this host?
Master running (pid=48213)
$ ssh -O exit web      # close it and every session on it
```

The caveats are real, and they are why some people leave this off:

- **Everything shares one connection.** If the network drops, every session on it dies at once.
- **Settings are fixed by the master.** A new session with different options, such as a port forward, joins the existing connection, and options that act at connection level, the user and the jump path among them, are the master's. When a config change seems to have no effect, run `ssh -O exit` first.
- **The socket is a credential.** Anyone who can open the file can use your authenticated connection, which is why it belongs in a directory only you can read.
- **Windows OpenSSH does not support it.** The built-in client has no Unix-socket multiplexing, and a `ControlMaster` line produces errors rather than speed. Use a `Match exec` guard, keep it in a separate file for your Linux and macOS machines, or use WSL.

#### Windows OpenSSH: paths and traps

Windows 10 (1809 and later) and Windows 11 ship the OpenSSH client as `C:\Windows\System32\OpenSSH\ssh.exe`, and it reads the same config format. The user file is `C:\Users\<you>\.ssh\config`, and `~` in `IdentityFile` works. Paths with spaces need double quotes; forward slashes avoid escaping questions entirely:

C:\Users\you\.ssh\config:

```text
Host web
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

Host db
    HostName 10.0.0.20
    User deploy
    IdentityFile "C:/Users/you/Key Store/id_db"
    ProxyJump web
```

The traps are all outside the syntax.

**Encoding.** In Windows PowerShell 5.1, `echo "Host web" > config` writes UTF-16, and ssh rejects the file with a garbled `Bad configuration option` on line 1. Create the file with `New-Item -ItemType File $HOME\.ssh\config` and edit it in an editor that saves plain UTF-8. PowerShell 7 writes UTF-8 by default and does not have the problem.

**Key permissions.** Windows ssh checks the ACL of private keys and refuses one that other accounts can read, with `UNPROTECTED PRIVATE KEY FILE`. That usually happens to a key copied in from elsewhere. Remove inherited permissions and grant only yourself:

```text
icacls "$HOME\.ssh\id_ed25519" /inheritance:r
icacls "$HOME\.ssh\id_ed25519" /grant:r "$($env:USERNAME):(R)"
```

**The agent is a service.** `AddKeysToAgent yes` does nothing until the OpenSSH Authentication Agent runs. From an elevated PowerShell:

```text
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
```

**Two ssh programs.** Git for Windows ships its own ssh in `C:\Program Files\Git\usr\bin`. It reads the same `~/.ssh/config` but uses its own agent, so keys added with the Windows `ssh-add` are invisible to Git. Pointing Git at the system client fixes that: `git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"`. VS Code's Remote-SSH extension reads the same config file, so every `Host` name you define appears in its list.

#### Recipes that come up again and again

##### Two GitHub accounts on one machine

GitHub identifies you by key, and every account connects as `git@github.com`, so a second account needs a second key and an alias:

~/.ssh/config:

```text
Host github-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_work
    IdentitiesOnly yes
```

Clone with the alias in place of the hostname - `git clone git@github-work:company/app.git` - and test with `ssh -T github-work`. Without `IdentitiesOnly`, the agent may offer the personal key first, GitHub accepts it, and you push as the wrong person.

##### Reaching a database through SSH

A database that listens only on `127.0.0.1` on a server can be reached from your laptop with a forward, kept in the config:

~/.ssh/config:

```text
Host web-pg
    HostName 203.0.113.10
    User deploy
    LocalForward 15432 127.0.0.1:5432
    SessionType none
```

`ssh web-pg` then holds a tunnel open with no shell (`SessionType none` needs OpenSSH 8.7; on older clients use `ssh -N web-pg`), and a client on your machine connects to `localhost:15432`. It is often a better answer than opening the database port to the internet, which [PostgreSQL remote connections](https://www.renode.space/blog/postgresql-remote-connections-getting-started) discusses properly.

##### Splitting the file with Include

`Include` (OpenSSH 7.3 and later) reads other files, with wildcards, and relative paths are taken from `~/.ssh`. Put it at the very top, outside any block, or it applies only to the block it sits in:

~/.ssh/config:

```text
Include config.d/*

Host *
    IdentitiesOnly yes
    ServerAliveInterval 30
```

One file per client or project in `~/.ssh/config.d/` keeps a long config readable and lets you delete a whole project's hosts in one step.

##### Managed servers over SFTP

A game, app or web server on a hosting panel usually has no shell, but its SFTP access still benefits from a block. On RE:NODE, SFTP is available on every server with credentials issued per server rather than per account, so a block per server with the host, port and username shown in the panel lets `sftp mc-world` replace a long string, and editors that read the config list it by name. The password is still asked for each time; do not try to force a key with `IdentitiesOnly` on such a block. [SFTP and the file manager](https://www.renode.space/blog/sftp-and-the-file-manager) covers clients and the panel side.

#### A complete config to start from

Put together, a config for one VDS, a private machine behind it and GitHub looks like this:

~/.ssh/config:

```text
Include config.d/*

Host vds
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Host db
    HostName 10.0.0.20
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    ProxyJump vds

Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519

Host *
    IdentitiesOnly yes
    AddKeysToAgent yes
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ConnectTimeout 10
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 10m
```

Remove the three `Control` lines on Windows. If the server is fresh, [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds) covers the account and firewall on the other end, and [the ufw guide](https://www.renode.space/blog/ufw-firewall-guide) shows how to allow SSH only from the bastion once a jump host is in place.

#### Troubleshooting the config

| Symptom | Usual cause |
| --- | --- |
| Settings seem ignored | An earlier block set them first |
| `Too many authentication failures` | Agent offering every key; add `IdentitiesOnly yes` |
| `Bad owner or permissions` | Config or key writable by others |
| `Bad configuration option` on line 1 | UTF-16 file from PowerShell 5.1 |
| `administratively prohibited` on a jump | Bastion has forwarding disabled |
| Config change has no effect | A ControlMaster connection is still open |
| `Could not resolve hostname` | The name has no `Host` block, or a typo in it |

The method for anything else is the same three steps. `ssh -G name` shows what ssh thinks the settings are. `ssh -v name` shows which config lines it applied, which keys it offered and where the connection stopped; with `ProxyJump`, the output covers both hops, so read which one failed. `ssh -F /dev/null user@address` rules the file in or out entirely.

#### FAQ

##### Does scp, rsync and Git use my SSH config?

Yes. They all start the system ssh client, which reads `~/.ssh/config`, so a `Host` alias works anywhere you would write a hostname: `scp file web:/tmp/`, `rsync -a dir/ web:/srv/`, `git@github-work:org/repo.git`. Tools with their own SSH implementation, such as some GUI SFTP clients, may ignore the file or read only part of it.

##### What is the difference between ProxyJump and agent forwarding?

With `ProxyJump`, both logins happen on your machine and the middle host only passes bytes. With agent forwarding (`ssh -A`), you log into the middle host and then use your agent from there, so anyone with root on that machine can borrow your keys while you are connected. Use `ProxyJump` whenever the goal is to reach another machine.

##### Can I use a different port and a jump host at the same time?

Yes. `Port` in a block applies to that block's host, and each hop is resolved separately, so a bastion on port 2222 and a target on 22 need only their own `Port` lines. Inline, `ProxyJump jump@203.0.113.10:2222` does the same.

##### Is it safe to put ControlMaster in Host star?

On Linux and macOS it is safe for interactive use, as long as `ControlPath` points inside your own `~/.ssh` directory. Some people exclude hosts used for long file transfers, since one stalled master stalls every session on it. On Windows, leave it out entirely.

##### Why does ssh still ask which key, or use the wrong one?

Usually the agent holds several keys and `IdentitiesOnly` is not set, or the `IdentityFile` path has a typo and ssh quietly falls back to the defaults. `ssh -v` lists every key it offered, in order, and `ssh -G` shows which `identityfile` lines it actually resolved.

A RE:NODE VDS runs on Intel i9-9900K hardware in Germany and is prepared by hand within 24 hours of the order, so the SSH config on your side and the server on ours can both be set up the way this guide describes. Game, app and web servers get SFTP with credentials per server.

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

---

### Choosing a hosting provider: what to look at, from the inside

Published 2026-10-14 by RE:NODE Team. https://www.renode.space/blog/choosing-a-hosting-provider-from-the-inside

When we pick a host, we look at six things, in this order: what CPU the workload actually lands on and how fast one core of it is, what every "unlimited" on the page turns into once the terms are read, whether the status page tells the truth on a bad day, who restores your data and how, how much of the machine you can reach without asking anyone, and whether a person answers when you write in. Price comes after all of that, because a cheap plan that fails on any of the six is not cheap.

We run the RE:NODE platform - the orchestration that provisions, scales and recovers customer servers, the game, VDS, web, database and storage lines, and the monitoring and backup automation around them. That gives us an uncomfortable view of how hosting is sold versus how it is run. Most of what follows is the stuff operators know and the plan page does not say. It applies to any host, including us, and some of it is deliberately unflattering to small providers like ours.

There is a shorter checklist version in [choosing game server hosting](https://www.renode.space/blog/how-to-choose-game-server-hosting). This one is the reasoning behind it.

#### Why the spec sheet lies by omission

A plan page is written to be compared, so it lists the things that compare easily: gigabytes of RAM, gigabytes of disk, a number of cores, a price. None of those is false. The trouble is what they leave out, and from the inside we can tell you the omissions are not random.

RAM is easy to sell and easy to deliver honestly, because memory is either there or it is not. Disk size is the same. Cores are where it gets slippery, because "2 vCPU" says nothing about which CPU, how old it is, how fast one thread runs, or how many other customers are queued on the same physical cores. Bandwidth is slipperier still. And the things that decide whether you stay - backups, access, support, what happens at 3am - rarely appear on the plan page at all.

So the rule we ended up with is simple: the numbers on the page are the floor of what you need to know, not the answer. Everything below is about the questions those numbers do not answer.

#### CPU: generation and single-thread speed beat core counts

This is the single most misunderstood thing in hosting, and it matters most for game servers.

Most game servers do their important work on one thread. The simulation tick - moving entities, running game logic, deciding what every player sees next - runs on a main thread, and the other cores help with networking, chunk loading, saving and the like. Minecraft is the famous example, but the pattern is broad: when a server lags, it is usually one core at 100% while the rest sit idle. We wrote that up properly in [reading game server CPU usage](https://www.renode.space/blog/reading-game-server-cpu-usage-single-thread), and [game server CPU requirements](https://www.renode.space/blog/game-server-cpu-requirements) covers it game by game.

What that means for choosing a host:

- A newer CPU at a high clock usually beats an older CPU with more cores. Per-clock performance has moved a lot between generations, so "3.0 GHz" on a CPU from many years ago and "3.0 GHz" on a recent one are not the same speed.
- Many-core server chips are excellent at running many things at once and often unremarkable at running one thing fast. That is the right trade for a host packing in web apps. It is the wrong one for a single busy game server.
- A "vCPU" is usually one hardware thread, not one physical core. Two vCPU may be the two hyperthreads of a single core, which is less than two full cores when both are busy.

The question to ask any host is "which CPU model does this plan run on?" A good answer names a model. A fair answer says "it varies by node, here are the models we use". An evasive answer - "enterprise-grade processors" - tells you they would rather you did not know. In fairness to hosts, not all of them can promise a model, because nodes get replaced. But they can tell you what is in the fleet today.

On RE:NODE, the VDS line names its CPU (the Intel i9-9900K), and on the shared lines the plan's CPU figure is a percentage of one core enforced as a hard limit - 250 means 2.5 vCPU of throttled share, not 2.5 cores you get to burst past. A server sitting at its limit is slow, never suspended for it. We do not publish a model for the shared nodes, so if that matters to you, that is a question to ask us too.

##### How to check what you actually got

On a VDS you have a shell, so look:

```bash
$ lscpu | grep -E 'Model name|^CPU\(s\)|Thread'
$ sysbench cpu --threads=1 --time=30 run
$ vmstat 1 10
```

The first line tells you the model and how many threads you have. The `sysbench` run with one thread gives you an events-per-second number you can compare between hosts - run it on every candidate the same way. In `vmstat`, watch the `st` column: steal time is CPU your virtual machine wanted and the hypervisor gave to someone else. A few percent now and then is normal on shared hardware. Steal that sits high while your own load is low means a crowded host, and [shared CPU and noisy neighbours](https://www.renode.space/blog/shared-cpu-and-noisy-neighbours) explains what you can and cannot do about it.

On a game panel you usually have no shell, but the host's `/proc/cpuinfo` is often visible from inside the container, and the console's CPU graph against the limit tells you whether you are bounded by the share you bought.

#### What "unlimited" means

From the operator's side, nothing is unlimited. Every resource we work with has a physical ceiling: uplink capacity, disk throughput, inodes, power. When a plan says "unlimited", it means one of three things, and you want to know which.

| The page says | What it usually means | What to ask |
|---|---|---|
| Unlimited bandwidth | Not billed per GB, under a fair-use clause | What happens at the fair-use line? |
| Unlimited slots | No slot count enforced in software | What does the CPU and RAM handle? |
| Unlimited disk or sites | Limited by inodes, I/O or file count | Where is the inode or I/O limit? |

"Unmetered" is the honest word for bandwidth: you are not charged per gigabyte. It is not a promise that you can saturate a shared uplink all month. Every host with an unmetered plan has a point at which someone's traffic starts hurting other customers, and the only real question is whether they tell you where it is and what happens when you reach it - a throttle, an email, a suspension. Our own wording is that unmetered means not billed per gigabyte, not a licence to saturate a shared uplink, and [bandwidth and fair use](https://www.renode.space/blog/bandwidth-and-fair-use) goes into what that means in practice.

"Unlimited player slots" on a game server is the one that annoys us most, because it is technically true and practically meaningless. The game, the CPU and the RAM decide how many players fit, not a number in the panel. A host advertising unlimited slots on a small plan is selling a number you cannot use.

The test we apply: if a plan says unlimited, search the terms of service for "fair use", "acceptable use", "excessive" and "inode". Whatever the terms say is the real plan. If the terms say nothing at all, the limit exists anyway - it is just enforced by whoever is on shift, by judgement, without warning.

#### How to read a status page

Every host has a status page now, and most of them are green almost all the time. That alone tells you very little. Here is what we look at instead.

1. Who measures it. A status page fed by automated checks from outside the host's network is worth more than one somebody edits by hand after the fact. A hand-edited page is green until a human decides it should not be.
2. How fine-grained it is. One "All systems operational" banner for an entire company hides the fact that your node can be down while everything else is up. Per-node or per-service components are more honest.
3. The history. Scroll back. A page with no incidents in a year is either describing a remarkably lucky operation or not being updated. Real infrastructure has bad days, and the honest pages show them.
4. What the incident notes say. "We are investigating" followed by "resolved" with nothing in between teaches you nothing. Notes that say what broke, what was affected and what changed afterwards tell you how the team thinks.
5. Where it is hosted. If the status page lives on the same infrastructure it reports on, it goes down at exactly the moment you need it.

On uptime percentages: they are only meaningful with the measurement interval and the definition next to them. 99.9% over a 30-day month allows about 43 minutes of downtime; 99.99% allows about four. If the check runs every five minutes, anything shorter than that may never be seen. And an SLA usually means a partial credit off your bill, not your downtime back - read what the remedy is before you are impressed by the number.

For what it is worth, RE:NODE's [status page](https://www.renode.space/status) samples availability every five minutes and we do not publish an uptime percentage or an SLA. We would rather say that plainly than print a figure. You should weigh that the same way you would weigh it on any host.

#### Backups, and who restores them

This is where we have the strongest opinions, because backup automation is a large part of running a platform, and because the gap between "we do backups" and "you can get your data back" is where most hosting disappointment lives.

There are two different things a host can mean by "backups":

- Platform backups - the host copies its own disks so it can recover the machine if hardware fails. You usually cannot see these, cannot choose what is in them and cannot restore a single file from them yourself. They protect the host's ability to rebuild, not your ability to undo a mistake.
- Customer backups - copies of your server that you can make, schedule, restore and download.

You want to know which one a plan includes, and you want the second kind. Then you want answers to these:

- Can I restore it myself, or do I file a ticket and wait?
- Can I download it, so a copy exists outside this host entirely?
- Where is it stored? A backup on the same disk as the data it protects is a convenience, not a backup.
- What happens to backups when I cancel or delete the server?
- Has anyone restored one recently?

That last one matters most. As the house line goes, a backup nobody has restored is a hypothesis. We have strong views on testing restores before you need them, and they are in [testing a restore before you need it](https://www.renode.space/blog/testing-a-restore-before-you-need-it) and [backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore).

On RE:NODE, every plan has backup slots: on demand or scheduled, restored with a button, downloadable, lockable against rotation and stored off the machine they protect. Deleting a server deletes its backups, locked ones included - which is exactly the kind of detail you should look for on any host, because people learn it the wrong way. Separately, the platform takes nightly backups copied to separate hardware. Neither replaces a copy you keep yourself, and we would say that about any host's backups, RE:NODE's included.

#### Panel, file and console access

The question here is how much of your own server you can reach without asking anyone. The less you can reach, the more every small problem becomes a support ticket, and the harder it is to leave.

For a game server or app host on a panel, the minimum we would accept:

- A live console with the raw, unfiltered output and a command line. If a host only shows you a cleaned-up log or a "server is running" badge, you cannot diagnose anything yourself.
- A file manager and SFTP. The file manager is for quick edits; SFTP is how you move a whole world, a modpack or a site in and out. No SFTP is a red flag on its own.
- The startup command or at least its variables. You will eventually need a launch flag, a JVM option or a different branch.
- Resource graphs against the limits, so you can see whether you are hitting the CPU share or the memory ceiling.
- Control over who else gets access, with permissions narrower than "everything".

For a VDS, the minimum is root, a way to reinstall, and some out-of-band console (VNC or serial) for when you lock yourself out with a firewall rule. Ask what you get if SSH stops answering. "Open a ticket" is a weak answer.

There is also an exit question hiding in here. Can you get all of your data out, in a normal format, without the host's help? If the answer is yes, a bad host costs you a weekend. If it is no, it costs you whatever they decide.

What the RE:NODE panel gives every plan, without tiers: an unfiltered live console with a command line, a file manager, SFTP with credentials per server rather than per account, a Startup tab for game and environment variables, CPU, memory and disk graphs against the limits, and subusers with granular permissions. It is built on Pterodactyl and Wings with our own panel on top, so if you know Pterodactyl, the shape will be familiar - [what Pterodactyl is](https://www.renode.space/blog/pterodactyl-panel-explained) covers that.

#### Support you can reach

The thing nobody can tell from a plan page is what support is like, and it is the thing that decides whether you stay. Hosting is a support business with servers attached.

What to look for:

- More than one channel, and at least one that leaves a written trail. A ticket system where your attachments stay private beats a public Discord channel for anything involving credentials or logs.
- A clear statement of when people are actually there. "24/7 support" from a small host often means "someone gets a notification". That is not dishonest, but it is not the same as a person on shift. We would rather a host told you its real hours.
- Answers written by someone who read the question. A pasted knowledge-base link in reply to a specific question is a sign of a team triaging by keyword.
- Whether support can do things, not just relay them. Can the person answering restore a backup, open a port, move a server? Or do they open an internal ticket and wait for an engineer?

Here is the uncomfortable thing from the operator side: small hosts are often better at support than large ones, because the person answering frequently wrote the code, and worse at coverage, because there are fewer of them. Big hosts tend to be the reverse. Decide which you need. A hobby server can wait until morning; a paid community or a business app may not.

RE:NODE support is a ticket from the panel (it reaches all staff, with private attachments), Discord and email. We do not advertise round-the-clock staffing, and we would not trust a host our size that did.

#### Testing a host in the first week

You learn more in the first week on a host than from any amount of reading reviews. Treat the first payment as the cost of a test, and run the test deliberately. Where a host offers a short term, take it.

| When | Test | What a pass looks like |
|---|---|---|
| Day 1 | Route from your players | Low, steady ping and little loss in `mtr` |
| Day 1 | CPU and disk benchmark | Numbers close to other hosts on the same tier |
| Days 1-7 | Load at your peak hours | No steal or slowdown at busy times |
| Day 2 | Support question | A specific answer from a person |
| Day 3 | Backup and restore | You restore and download it yourself |
| Day 5 | Data export | Everything comes out over SFTP or a dump |

The route test matters first, because no amount of CPU fixes a bad path between the machine and your players. Run it from where your players actually are, not from where you are:

```bash
$ mtr -rwc 100 203.0.113.10
$ fio --name=rand --rw=randread --bs=4k --size=1G --iodepth=32 \
    --ioengine=libaio --direct=1 --runtime=30 --time_based
```

The address there is a documentation example - put in your server's. [Reading traceroute and mtr](https://www.renode.space/blog/reading-traceroute-and-mtr) explains how to read the output, and the most common misreading: loss on a middle hop that does not continue to the last hop is usually a router deprioritising the probes, not real loss.

Then load the server the way you will use it, at the hours you will use it. A machine that benchmarks well at 10am on a Tuesday and crawls on Saturday evening has a capacity problem that a single benchmark will not show. Repeat the one-thread `sysbench` run at a few different times and compare.

Ask support one real question you already know the answer to, and note how long the reply takes and whether it is right. Take a backup, delete a file you can live without, restore it, download the archive, and open it on your own machine. Pull your data out the way you would if you were leaving. If any of these is hard in week one, it will be worse in month six.

#### Red flags

Some of these are dealbreakers; some are just a reason to ask harder questions. We have tried to only list things we would also hold our own platform to.

- No CPU model anywhere, and evasion when you ask.
- "Unlimited" everything, with no fair-use section you can find.
- A status page that has never shown an incident.
- A DDoS protection claim with a large number and no explanation of what is filtered. Real protection drops obvious volumetric floods; attacks that look like real players are much harder, and anyone promising to stop everything is selling you a sentence, not a service. There is more on that in [DDoS attacks on game servers explained](https://www.renode.space/blog/ddos-attacks-on-game-servers-explained).
- No SFTP, no raw console, or backups you can only restore through a ticket.
- No way to export your data, or an export locked behind a cancellation.
- Lifetime plans, or prices so far below everyone else's that the arithmetic cannot work. Hardware, power and bandwidth cost the same for everyone in a given market; a plan priced below that is subsidised by something - overselling, a short life, or both.
- Terms that let the host suspend you for using the resources you paid for. Running at 100% of your CPU share should make your server slow, not get it removed.
- Pressure to buy a long term up front before you have tested anything.

None of these is proof a host is bad. Several together usually are.

#### The trade-offs we would make again

A few opinions, stated plainly, from running one of these.

We would take a host that admits its limits over one that hides them. Every host has a ceiling on bandwidth, a support rota with gaps, a location that suits some players and not others. The ones that write those down are the ones you can plan around. RE:NODE runs in one location, Germany, on hardware we run - that is the right answer for players in Europe and the wrong one for players far away, and we would rather you knew that before you paid.

We would take fewer features and more access. A panel with an honest console, SFTP and real backups beats one with a hundred one-click buttons and no shell.

We would always keep our own copy of anything that matters, on someone else's infrastructure. Not because hosts are careless, but because the failure you are protecting against might be the host itself - a billing mistake, a closed account, a company that stops trading.

And we would test before committing. Every host, including ours, looks better on its own website than it does at 3am. The first week is where you find out which ones look the same.

If you want the wider view on what kind of server to rent in the first place, [VPS vs VDS vs dedicated server](https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server) is the place to start.
#### FAQ

##### Is a higher core count always better for a game server?

No. Most game servers are limited by how fast one core is, because the main simulation loop runs on a single thread. A plan with fewer cores on a newer, higher-clocked CPU will usually run a busy game server better than more cores on an older or many-core chip. Extra cores help with mods, plugins, chunk generation and running several servers side by side.

##### What is the difference between unmetered and unlimited bandwidth?

Unmetered means you are not billed per gigabyte transferred. Unlimited is a marketing word for the same thing and should never be read as no ceiling at all - every host has a shared uplink and a fair-use point. The useful question is what the host does when you reach it: throttle, warn or suspend.

##### Should I trust a 99.9% uptime guarantee?

Treat it as a statement about compensation, not about reliability. It allows about 43 minutes of downtime in a 30-day month, the measurement interval decides what counts, and the remedy is usually a partial credit. A status page with real incident history tells you more about how a host operates than the percentage does.

##### Are the host's backups enough on their own?

They are a good first line and nothing more. Make sure you can restore and download them yourself, and keep at least one recent copy somewhere else entirely - your own machine or separate object storage. If the problem is the host account itself, backups stored with that host do not help.

##### How long should I test a new host before committing?

A week of normal use, including your busiest hours, is enough to find most problems: route quality, CPU contention at peak, support response, and whether restores and exports actually work. Avoid paying for a long term until that week is behind you, even if it is cheaper per month.

##### Is a small host riskier than a big one?

Differently risky. A small host is more likely to have support from people who built the system and less likely to have staff on shift at every hour. A big host has more coverage and more layers between you and an engineer. Your own backups and an easy way to export your data make either choice much safer.

Every RE:NODE plan gets the same panel: an unfiltered console, a file manager, SFTP per server, and backups you restore with a button and can download. Game, app, web and database servers are usually ready inside a minute of payment, on NVMe in Germany.

[See RE:NODE plans](https://www.renode.space/game-servers)

---

### Docker Compose for small stacks

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/docker-compose-for-small-stacks

Compose is a text file that describes several containers and the wiring between them, and a command that makes reality match the file. On one server that is worth more than it sounds. Without it, a three-container stack is three `docker run` lines with twenty flags between them, remembered wrongly six weeks later. With it, the whole thing is `docker compose up -d`, the configuration is a file you can keep in git, and rebuilding the stack on a new machine is a `git clone` and one command.

What Compose is not is a scheduler. It will not move a container to another host, it will not restart something that has gone unhealthy, and it has no opinion about downtime. On a single VDS none of that is a loss, because there is only one host and the alternative was a shell script. This post builds a realistic stack - a reverse proxy, an application and a PostgreSQL database - and then covers the parts that decide whether it survives a year: where the data is, what `down` deletes, how secrets get in, and what to do when it will not come up.

#### What Compose is, and the file it reads

Compose v2 is a plugin, invoked as `docker compose` with a space. The old Python `docker-compose` with a hyphen is a separate, retired program; if a tutorial uses the hyphen it predates the current tooling, though the file format is largely the same.

Compose looks for, in order: `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`. The first name is the current convention and the last is the one everybody has. Either works. What no longer works usefully is the `version: "3.8"` line at the top of old files - it is obsolete in Compose v2, it is ignored, and it produces a warning. Delete it.

One concept to grasp before the file makes sense: the **project**. Compose namespaces everything it creates by project name, which defaults to the directory name. A `compose.yaml` in `/srv/notes` produces containers called `notes-app-1` and `notes-db-1`, a network called `notes_default` and a volume called `notes_dbdata`. Two projects in two directories do not collide. This is also why moving the directory, or renaming it, can suddenly make Compose think it is looking at a brand new stack with no volumes - pin the name with a top-level `name:` key if the directory is likely to move.

#### One file, three services

Here is a stack that actually works: Caddy in front for TLS, a Node application, and PostgreSQL behind both.

compose.yaml:

```yaml
name: notes

services:
  proxy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

  app:
    image: ghcr.io/example/notes:1.4.2
    restart: unless-stopped
    env_file:
      - app.env
    environment:
      DATABASE_URL: postgres://notes:${DB_PASSWORD}@db:5432/notes
      NODE_ENV: production
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: notes
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  dbdata:
  caddy_data:
  caddy_config:
```

And the proxy configuration it mounts, which is three lines because Caddy obtains and renews the certificate itself once the DNS name points at the machine:

Caddyfile:

```text
notes.example.com {
    encode gzip
    reverse_proxy app:3000
}
```

Notice what is absent. The application has no `ports:` entry and neither does the database. Neither of them is reachable from outside the machine at all. Only the proxy publishes anything, and it publishes the two ports the internet already expects.

```diagram
title: One public door in front of two private services
net = Internet | ports 80 and 443
proxy = Caddy | publishes 80 and 443
app = Node app | listens on :3000
db = PostgreSQL | volume dbdata
net -> proxy : TLS on 443
proxy -> app : http://app:3000
app -> db : TCP 5432
```

#### The network, and why only the proxy publishes a port

Compose creates one network per project and attaches every service to it. On that network, Docker's embedded DNS resolves each service name to its container, so `app` reaches the database at the hostname `db` on port `5432` with no configuration at all, and the proxy reaches the application at `app:3000`. Nothing needs to know an IP address, and the addresses change on every recreate anyway.

The important consequence: **ports between services on the same Compose network are already open**. `ports:` is not about letting containers talk to one another; it is about punching a hole from the host into a container. Every `ports:` entry you add is a new public listener, so add them only for the front door.

This also solves a problem that catches people the first time. A published port bypasses UFW - `ports: - "5432:5432"` on the database makes PostgreSQL reachable from the internet even with a default-deny firewall, because the packet is forwarded to the container instead of delivered to the host. The full mechanism is in [the UFW firewall guide](https://www.renode.space/blog/ufw-firewall-guide) and the shorter version is in [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started). If you must publish a port for debugging, publish it to loopback - `"127.0.0.1:5432:5432"` - and reach it over an SSH tunnel.

If you prefer nginx to Caddy, the same shape applies with a longer configuration file, including the header lines a proxied application needs to see the real client. [The nginx reverse proxy guide](https://www.renode.space/blog/nginx-reverse-proxy-guide) has that server block, and [what a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) explains why `X-Forwarded-For` exists at all.

#### Environment variables and the two kinds of env file

This confuses nearly everyone, because there are two mechanisms with similar names doing different jobs.

**The `.env` file in the project directory** is read by Compose itself, before anything starts, and its values substitute `${VAR}` placeholders inside `compose.yaml`. It configures the file, not the containers. If `.env` contains `DB_PASSWORD=hunter2`, then `${DB_PASSWORD}` in the compose file becomes that value.

**`env_file:` on a service** hands a file of `KEY=value` lines to that container as environment variables. Compose never looks inside it. This is where application configuration belongs.

.env - read by Compose, substituted into compose.yaml:

```env
DB_PASSWORD=a-long-random-string
APP_TAG=1.4.2
```

app.env - handed to the app container:

```env
SESSION_SECRET=another-long-random-string
SMTP_HOST=smtp.example.com
LOG_LEVEL=info
```

Both files hold secrets, so both want `chmod 600` and both belong in `.gitignore`, with a committed `app.env.example` listing the keys and no values. Variables set under `environment:` override anything from `env_file:`, which is the precedence to remember when a value is mysteriously not what the file says.

Check what Compose actually resolved before blaming the application:

```bash
$ docker compose config
$ docker compose config --services
```

`docker compose config` prints the fully merged and interpolated file. An empty value where you expected a password means the `.env` file is not where Compose is looking, which is nearly always because you ran the command from the wrong directory. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the wider question of what should be an environment variable in the first place.

For anything you would rather not have in a process environment, Compose supports file-based secrets: a `secrets:` block names a host file, and the service gets it mounted read-only at `/run/secrets/<name>`. Many images take a `..._FILE` variant of their password variable specifically so you can point it there.

#### Volumes: what must survive a `down`

Every service in the example above is disposable except for what is in the named volumes. That is deliberate, and it is the mental model to keep: **containers are cattle, volumes are the herd**.

| Path | Kind | Why |
| --- | --- | --- |
| `dbdata:/var/lib/postgresql/data` | named volume | The database. Losing it loses everything |
| `caddy_data:/data` | named volume | Issued certificates and ACME account keys |
| `./Caddyfile:/etc/caddy/Caddyfile:ro` | bind mount | You edit it, so keep it in the repo |

`caddy_data` is easy to forget and annoying to lose: without it, every recreate of the proxy re-requests certificates from scratch, and certificate authorities rate-limit issuance per domain per week. A few careless rebuilds and you are locked out of new certificates for days on a domain that is serving real traffic.

> [!WARNING]
> `docker compose down` removes the containers and the network and leaves named volumes alone. `docker compose down -v` deletes the volumes too, and there is no confirmation prompt. The `-v` is the difference between restarting a stack and destroying a database. Never type it from muscle memory in a production directory.

Backups are a database dump on a schedule, not a copy of the data directory while it is being written to:

```bash
$ docker compose exec -T db pg_dump -U notes -Fc notes > /srv/backups/notes-$(date +%F).dump
$ docker compose exec -T db psql -U notes -d notes < /srv/backups/restore-test.sql
```

The `-T` matters: without it, `exec` allocates a pseudo-terminal and corrupts a redirected binary dump with line-ending translation. Put the first line in a cron job or a systemd timer, copy the results off the machine, and then restore one somewhere harmless to prove the file is real. A backup nobody has restored is a hypothesis - [database backups and restores](https://www.renode.space/blog/database-backups-and-restores) is the longer argument.

#### depends_on, healthchecks and start order

`depends_on` in its short form controls start order only. It waits for the container to be created and started, not for the software inside it to be ready to answer. A PostgreSQL container takes several seconds to initialise on first run, so an application that connects at boot will fail against a database that is technically running.

The long form fixes it by waiting on a healthcheck:

```yaml
    depends_on:
      db:
        condition: service_healthy
```

Which requires the database to define one, as the example does with `pg_isready`. The four knobs are worth understanding: `interval` is how often the check runs, `timeout` is how long one attempt may take, `retries` is how many consecutive failures make it unhealthy, and `start_period` is a grace window at the beginning during which failures do not count. A database restoring a large dump on first start needs a generous `start_period` or it will be declared unhealthy while it is doing exactly what it should.

The three conditions available are `service_started` (the default short-form behaviour), `service_healthy`, and `service_completed_successfully`, the last of which is how you run a migration job before the application starts.

Even with all this, write the application to retry its database connection with a short backoff. Start ordering solves the first thirty seconds; a database restart at three in the morning is the other case, and a service that exits permanently because a connection failed once is a service that needs a human at three in the morning. [Migrations without downtime](https://www.renode.space/blog/migrations-without-downtime) covers the schema side of the same problem.

#### The commands you will actually type

```bash
$ docker compose up -d              # create or update everything, detached
$ docker compose ps                 # what is running, and its health
$ docker compose logs -f --tail 100 app
$ docker compose exec app sh        # a shell in the running container
$ docker compose run --rm app npm run migrate
$ docker compose restart app        # restart without recreating
$ docker compose stop               # stop, keep everything
$ docker compose down               # remove containers and network
```

`up -d` is idempotent and is the command you use for almost everything. It compares the file with reality and recreates only the services whose definition changed, which means editing one service's image tag and running `up -d` touches that service alone and leaves the others running.

The difference between `exec` and `run` catches people: `exec` enters a container that is already running, `run` starts a new one from the same definition. Use `run --rm` for one-off tasks like a migration or a management command, and `exec` for looking at something live.

Two more worth knowing. `docker compose pull` fetches newer images without touching the running stack, so you can pull at a convenient moment and switch at a quieter one. And `docker compose --profile debug up -d` starts services tagged with a `profiles:` key, which is how to keep an admin tool or a database GUI in the same file without running it all the time.

#### Updating, rolling back and the release you regret

Updating is two commands, and the tag in the file is what makes them safe:

```bash
$ docker compose pull app
$ docker compose up -d app
```

Because the images are pinned to `1.4.2` rather than `latest`, `pull` only fetches something new when you have edited the file - which is the point. Deploying is then an edit to one line, a commit, and `up -d`. Rolling back is the same edit in reverse, and it works because you have not deleted the old image yet; `docker image ls` will show it sitting there. Keep at least the previous tag until the new one has survived a day.

Two things this simple flow does not give you. There is a gap of a few seconds while the old container stops and the new one starts, during which the proxy has nothing to talk to and visitors get a 502. For a hobby stack that is acceptable; if it is not, [zero-downtime deploys on a small server](https://www.renode.space/blog/zero-downtime-deploys-on-a-small-server) covers the patterns that close the gap. And the database is not versioned with the application, so a release that requires a schema change needs the migration to run in a separate step, ideally one that is safe against both the old and the new code.

For the stack to come back after a reboot, `restart: unless-stopped` on every service is usually enough, since the Docker daemon starts at boot and brings them with it. If you want the stack tied to the machine's own startup ordering, or you want it to wait for a mounted filesystem, wrap `docker compose up -d` in a small systemd unit instead - [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) has the file.

#### When the stack will not come up

**`service "app" depends on undefined service db`.** A typo or an indentation error. YAML is whitespace-significant and two spaces is the convention; a tab anywhere in the file is a syntax error. `docker compose config` catches both before you start anything.

**Port is already allocated.** Another process, or another Compose project, holds the host port. `sudo ss -lntp` names it. Two projects cannot both publish port 443; that is what the proxy is for.

**The app cannot resolve `db`.** Both services must be in the same project and the same network. If you split the stack across two compose files, they get two networks - declare an external network in both, or keep them in one file.

**The database starts fresh every time.** The volume is not attached where the image expects it, so the data is going into the container's writable layer and dying with it. `docker compose exec db ls -la /var/lib/postgresql/data` should show files, and `docker volume ls` should show a volume with the project prefix.

**Changing `POSTGRES_PASSWORD` had no effect.** The official database images use those variables only when initialising an empty data directory. On an existing volume the password is whatever it was on day one, and you change it with `ALTER USER` inside the database.

**Everything is healthy and the site returns 502.** The proxy is reaching the wrong port or the wrong name. Check that the application actually listens on `0.0.0.0` inside the container rather than `127.0.0.1`, because a container's loopback is not shared with the proxy's.

**Out of disk.** Old images and build cache. `docker system df` shows where it went; `docker image prune -a` and `docker builder prune` reclaim it safely. Avoid the `--volumes` flag unless you are certain.

A stack like this fits comfortably in a small VDS: the proxy needs almost nothing, PostgreSQL is happy in a gigabyte for a small site as long as you tune it, and the application is whatever it is. [PostgreSQL tuning for small servers](https://www.renode.space/blog/postgresql-tuning-for-small-servers) is the settings side of that. What a VDS gives you that a managed plan cannot is exactly this: a kernel, a Docker daemon and root, so the stack in your file is the stack that runs.

#### FAQ

##### Is Compose overkill for one container?

Slightly, but it still pays for itself. A one-service file records the flags you would otherwise retype, makes updates two commands, and costs nothing at runtime. The moment you add a database it becomes the obvious way to do it.

##### Should I use `build:` or a prebuilt image?

Build elsewhere and pull a tagged image if you can. Building on the server ties deployments to the box's CPU and disk, fills the build cache, and means a failed build leaves you with nothing running. Building in place is acceptable for a small project as long as you keep the previous image.

##### Does `docker compose down` delete my database?

Not on its own. It removes containers and networks and keeps named volumes. `docker compose down -v` deletes the volumes, and that one does destroy the database with no prompt.

##### How do I run several stacks on one server?

One directory each, one `compose.yaml` each, and only one of them publishing ports 80 and 443. Put the shared proxy in its own project on an external network that the others join, or give each application a distinct internal port and route by hostname in the single proxy.

##### Can I use Compose with a managed hosting plan?

No. Compose drives a Docker daemon, and a managed game or app plan is itself a container without one. That is one of the clearer reasons to move to a VDS, where the daemon and the root account are yours.

##### What replaces Compose when this stack grows?

Usually nothing, for a long time. One server with a handful of services is exactly the size Compose fits. When you genuinely need more than one machine, the next step is a scheduler, and the step after that is a team that maintains it - both are large costs that are easy to take on too early.

A proxy, an app and a database in one Compose file need a machine with root and a Docker daemon: the RE:NODE VDS line is an Intel i9-9900K in Germany with NVMe on a ZFS pool and full root access, from 8 GB and two vCPU up to 24 GB and six. Each one is prepared by hand, so it arrives within 24 hours rather than in a minute.

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

---

### Docker on a VDS: images, volumes and ports

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/docker-on-a-vds-getting-started

Docker on a single server is not orchestration and is not a cloud. It is a way to install software without installing it: one command brings down a tested image, runs it as an isolated process tree with its own filesystem, and leaves your operating system with nothing on it but Docker. Nothing is deployed for you, nothing scales, nothing heals. What you get is repeatability - the same container behaves the same way on your laptop and on the box in Germany - plus the ability to run two versions of the same database without a fight.

The whole job on one machine comes down to five decisions per container: which image and which tag, which ports are published and to what address, where the data lives, what happens when it exits, and what stops it eating the disk. This post covers those five in order, with the commands, and then the ways people lose data.

#### What Docker changes on a single server

The honest summary of what you gain:

- **Installation becomes a pull.** No PPAs, no compiling, no "this needs Python 3.9 and the box has 3.11". The image carries its own userland.
- **Versions stop fighting.** PostgreSQL 15 and 17 can run side by side on one machine, each with its own data directory and its own port, and neither one knows.
- **Removal is complete.** `docker rm` takes the process and the filesystem with it. Nothing is left in `/usr`, `/etc` or a package database.
- **Configuration becomes a command you can write down.** The `docker run` line is the install instructions, and you can keep it in a file - which is what [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) is.

And what it does not give you, which matters more because people assume otherwise. Containers are not virtual machines: they share the host kernel, so a kernel panic or an out-of-memory event on the host takes everything with it. They are not a security boundary against root - anyone who can run `docker` can mount the host filesystem into a container and read anything. They do not make software faster; the overhead is close to zero but so is the benefit. And they do not back anything up.

One prerequisite: this needs a real kernel of your own. On KVM, VMware, Hyper-V or Xen, Docker works. On container virtualisation such as OpenVZ or LXC it ranges from awkward to impossible. Run `systemd-detect-virt` before you plan around it - [VPS, VDS or dedicated server](https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server) goes through what that one word of output tells you about the plan you bought.

#### Installing Docker, and the group that is really root

Two supported paths. The convenience script is fine on a machine you own:

```bash
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
$ sudo systemctl enable --now docker
$ docker version
```

The repository method is the same packages with more steps, and it is what you want in anything you will repeat. Either way the pieces you end up with are `docker-ce` (the daemon), `docker-ce-cli` (the command), `containerd.io` (the runtime underneath), and the `docker-buildx-plugin` and `docker-compose-plugin` subcommands. Avoid your distribution's own `docker.io` package: it lags behind and does not reliably bring the Compose plugin with it, which you will want within a week.

Then the part everyone skips:

```bash
$ sudo usermod -aG docker deploy
$ newgrp docker
$ docker run --rm hello-world
```

> [!WARNING]
> Membership of the `docker` group is equivalent to root on the machine. Anyone in it can start a container that bind-mounts `/` and edit any file on the host, with no password and no `sudo` log entry. Add your own deployment user and nobody else. On a server with several people on it, leave the group empty and make everyone type `sudo docker`, so the actions land in the audit log.

If `docker run hello-world` prints the welcome text, the daemon is up, the network works and image pulls work. That is three tests in one command, and it is worth doing before you debug anything more complicated.

#### Images and containers: the first run

An image is a stack of read-only filesystem layers plus metadata saying what to execute. A container is one running instance of an image with a thin writable layer on top. Deleting the container deletes that writable layer - which is why anything you care about has to live in a volume, three sections down.

```bash
$ docker run -d \
    --name web \
    --restart unless-stopped \
    -p 127.0.0.1:8080:80 \
    -v /srv/web/html:/usr/share/nginx/html:ro \
    nginx:1.27-alpine
```

Reading that line left to right: run detached, call it `web`, bring it back after a reboot, publish container port 80 as port 8080 on loopback only, mount a host directory read-only, from the `nginx` image at tag `1.27-alpine`.

The tag is a decision, not decoration:

| Tag style | Example | Use it when |
| --- | --- | --- |
| `latest` | `nginx` | Never on a server |
| Major | `nginx:1` | You trust the project's compatibility promise |
| Minor | `nginx:1.27` | Sensible default: security fixes, no surprises |
| Exact | `nginx:1.27.2` | You want to choose every upgrade yourself |
| Digest | `nginx@sha256:...` | The build must be byte-identical every time |

`latest` is not a channel and carries no promise. It is simply the tag applied when nobody specified one, and it moves whenever the maintainer feels like it. A `docker pull` six months later can hand you a new major version, and the first you hear of it is a container that will not start. Pin at least the minor version on anything that holds data.

Day-to-day commands, all of which you will use hourly:

```bash
$ docker ps                      # running containers
$ docker ps -a                   # including the dead ones
$ docker logs -f --tail 100 web  # follow the output
$ docker exec -it web sh         # a shell inside it
$ docker stop web && docker rm web
$ docker inspect web             # every setting, as JSON
```

`docker stop` sends `SIGTERM`, waits ten seconds, then sends `SIGKILL`. If your application needs longer to shut down cleanly - flushing a write, finishing a request, saving a world - raise it with `docker stop -t 60` and make sure the process actually handles `SIGTERM`. [Graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers what handling it properly looks like in application code.

#### Ports, publishing and the firewall

`-p` sets up destination NAT from a host port to a container port. Written as `-p 8080:80`, it binds to every address on the machine, including the public one. Written as `-p 127.0.0.1:8080:80`, it binds to loopback only.

Prefer the second form. Almost everything you run belongs behind something else - a reverse proxy for anything speaking HTTP, nothing at all for a database that only your application uses. The rule is: publish a port only if the internet is supposed to reach it directly.

This is not a style preference. **Published ports bypass UFW.** A container published with `-p 8080:80` is reachable from the internet even when `ufw status` reports a default-deny policy with no rule for 8080, because the packet is forwarded to the container rather than delivered to the host and Docker's forwarding rules are evaluated first. The full explanation and the four ways around it are in [the UFW firewall guide](https://www.renode.space/blog/ufw-firewall-guide); the one-line version is to publish to `127.0.0.1` and let a proxy on the host handle the public side.

Containers that need to talk to each other should share a user-defined network instead of publishing anything:

```bash
$ docker network create appnet
$ docker run -d --name db  --network appnet -e POSTGRES_PASSWORD=... postgres:17
$ docker run -d --name app --network appnet -p 127.0.0.1:3000:3000 my/app:1.4
```

On a user-defined network, Docker runs an embedded DNS server, so the application connects to the host name `db` on port `5432` and nothing is exposed anywhere. The default `bridge` network does not do this name resolution, which is why "it works in Compose but not with `docker run`" is nearly always a missing `docker network create`.

#### Volumes, and where your data actually lives

A container's writable layer dies with the container. There are two ways to keep data, and they are for different jobs.

**Named volumes** are managed by Docker and stored under `/var/lib/docker/volumes/`. Use them for anything a program owns and you only ever touch through that program: database files, a game world, an application's state directory.

```bash
$ docker volume create pgdata
$ docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:17
$ docker volume ls
$ docker volume inspect pgdata
```

**Bind mounts** map a host path into the container. Use them for things you want to edit with your own editor: configuration files, a static site's HTML, a directory of uploaded media you also serve from nginx.

```bash
$ docker run -d --name web -v /srv/web/html:/usr/share/nginx/html:ro nginx:1.27-alpine
```

Three practical points. The `:ro` suffix makes a mount read-only and costs nothing - use it wherever the container has no business writing. Bind mounts carry the host's ownership into the container, so a process running as UID 1000 inside the container cannot write to a directory owned by root outside it; either `chown` the directory to the right numeric ID or run the container with `--user "$(id -u):$(id -g)"`. And a named volume that gets its initial contents from the image does so only when it is empty, which is why changing an image's default configuration after the first start appears to do nothing.

> [!WARNING]
> `docker system prune --volumes` and `docker volume prune` delete volumes that no container currently references. A stopped container is still a reference; a deleted one is not. The classic loss is removing a database container to recreate it with a new flag, running a prune in between, and finding the data gone. Back up before you prune, and see [backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore) for why the restore is the part that counts.

Backing up a volume is a container that mounts it and writes a tarball somewhere else:

```bash
$ docker run --rm -v pgdata:/data:ro -v /srv/backups:/out alpine \
    tar czf /out/pgdata-$(date +%F).tar.gz -C /data .
```

For a database, prefer the database's own dump tool over a filesystem copy of a live data directory - `pg_dump` through `docker exec` gives you a file that restores reliably, which a tarball of files being written to does not. [pg_dump and pg_restore](https://www.renode.space/blog/pg-dump-and-pg-restore-guide) has the flags.

#### Restart policies, reboots and health

A container does not come back on its own. By default, when the process exits or the machine reboots, it stays down.

| Policy | On crash | On `docker stop` | After a reboot |
| --- | --- | --- | --- |
| `no` (default) | stays down | stays down | stays down |
| `on-failure:5` | restarts, up to 5 times | stays down | restarts if it was failing |
| `always` | restarts | stays down until the daemon restarts | restarts |
| `unless-stopped` | restarts | stays down | stays down if you stopped it |

`unless-stopped` is the right answer for nearly everything on a small server. It survives reboots and crashes, and it respects the fact that you stopped a container deliberately - which `always` does not, cheerfully bringing back the container you stopped an hour ago the next time the daemon starts.

You can change the policy on a running container without recreating it:

```bash
$ docker update --restart unless-stopped web
```

Restart policies depend on the Docker service starting at boot, so confirm `systemctl is-enabled docker` says `enabled`. And note what a restart policy is not: it is not a health check. A container whose process is alive but wedged - a Node app that has stopped accepting connections, a game server stuck on a save - satisfies the restart policy perfectly. Docker's own `HEALTHCHECK` marks such a container unhealthy but does not restart it. If you want "restart when it stops answering", that is a job for a watchdog, or for a `systemd` unit wrapping the container with the health logic you want; [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) has the unit file for that shape.

#### Limits and logs: the two things that quietly fill a box

By default a container can use all the memory on the machine and all the CPU. On a server running one thing, that is fine. On a server running six, one runaway process takes the others with it.

```bash
$ docker run -d --name app \
    --memory=1g --memory-swap=1g --cpus=1.5 \
    my/app:1.4
$ docker stats --no-stream
```

Setting `--memory-swap` equal to `--memory` disables swap for that container, which is usually what you want: a process that has been pushed into swap is slow in a way that is harder to diagnose than an outright failure. When a container passes its memory limit the kernel kills the process inside it, the container exits with code 137, and `docker inspect` records it:

```bash
$ docker inspect -f '{{.State.OOMKilled}}' app
true
```

Exit code 137 is `128 + 9`, meaning `SIGKILL`. Exit code 143 is `128 + 15`, a clean `SIGTERM` - usually you stopping it. Those two numbers answer most "why did it die" questions on their own. [Linux swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer) explains what the kernel is deciding when it picks a victim.

The quieter problem is logs. Docker's default logging driver writes everything a container prints to a JSON file under `/var/lib/docker/containers/`, **with no size limit at all**. A chatty application will fill the disk over weeks or months, and the symptom is a server that fails in several unrelated ways at once. Fix it globally:

/etc/docker/daemon.json:

```json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
```

Then `sudo systemctl restart docker`. Note that this applies to containers created afterwards; existing ones keep the settings they were created with, so recreate them or set `--log-opt max-size=10m` on each. Check what you are carrying today with `sudo du -sh /var/lib/docker/containers/*`.

#### Updating, pruning and disk space

Updating a container is not an upgrade in place. You pull a new image, destroy the container and create a new one from the same volumes and the same run command:

```bash
$ docker pull nginx:1.27
$ docker stop web && docker rm web
$ docker run -d --name web --restart unless-stopped \
    -p 127.0.0.1:8080:80 -v /srv/web/html:/usr/share/nginx/html:ro nginx:1.27
```

This is exactly why writing the run line down matters, and exactly why Compose exists - `docker compose pull && docker compose up -d` does the same three steps from a file you already have. Keep the previous image tag until the new container has been running for a day; rolling back is then a matter of running the old tag again.

Disk usage creeps up in four places at once. This tells you where:

```bash
$ docker system df
TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images          14      4        6.2GB     4.9GB (79%)
Containers      7       4        112MB     43MB (38%)
Local Volumes   6       3        2.1GB     820MB (38%)
Build Cache     41      0        3.3GB     3.3GB
```

`docker image prune -a` removes images no container uses. `docker builder prune` clears the build cache, which on a box that builds its own images is often the largest single item. `docker container prune` removes stopped containers. Run those three on a schedule and you will rarely think about it again. Run `docker system prune --volumes` casually and you will eventually delete a database. The distinction is worth keeping in your fingers.

#### When a container will not start

**It exits immediately and `docker ps` shows nothing.** Look at `docker ps -a` for the exit code and `docker logs <name>` for the reason. A container with no long-running process in the foreground exits as soon as its command finishes - that is not a fault, it is the design.

**Port is already allocated.** Something else has the host port. `sudo ss -lntp | grep 8080` names it. Either stop it or publish on a different host port; the container port never has to change.

**Permission denied on a mounted directory.** UID mismatch on a bind mount. Check `ls -ln` on the host directory and the `USER` the image runs as, then align them.

**The image will not pull.** Check the tag actually exists, then check whether you are being rate limited - registries limit anonymous pulls by address, and `docker login` lifts that. On a fresh box also check DNS: containers use the host's resolver by default, and a broken `/etc/resolv.conf` shows up here first.

**It worked yesterday and today it is a different version.** You used `latest`. See the tag table.

**Everything is slow and the disk is full.** Logs or build cache. See the previous section.

RE:NODE's managed plans are containers too - the panel is a customised Pterodactyl with one container per server, a hard CPU throttle at the share you bought, and the same OOM behaviour described above, except that the whole container is stopped at the limit and restarted clean rather than left to swap. What you cannot do there is bring your own image, so if the software you want to run is not one of the catalogue lines, a VDS with root access and Docker on it is the honest answer. [Choosing between a VDS and a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) sets the two side by side, and [what a hosting panel actually is](https://www.renode.space/blog/pterodactyl-panel-explained) covers the managed side.

#### FAQ

##### Do I need Docker on a small VDS?

No, and for one application it can be an extra layer to learn for little gain. It earns its place the moment you run a second thing, need a version your distribution does not package, or want to move the same setup to a different machine unchanged.

##### How much overhead does a container add?

For CPU and memory, close to nothing - it is the same kernel running the same process with different namespaces. Container networking adds a small amount of latency through NAT, and a writable overlay filesystem is slower than a bind mount for heavy random writes, which is one more reason databases belong on volumes.

##### Where is my data if I delete a container?

In its volumes, which survive. Anything written inside the container but outside a volume is gone with the writable layer. Check what a container mounts with `docker inspect` before you remove it.

##### Can I run Docker inside a managed hosting plan?

No. A managed game or app plan is already a container, and you do not have a kernel or a daemon of your own inside it. Running Docker is one of the specific reasons to take a VDS instead.

##### Why is my container reachable from the internet when the firewall is on?

Because you published a port, and published ports are forwarded past the host firewall's input rules. Bind the publish to `127.0.0.1` unless the port is genuinely meant to be public, and put a reverse proxy in front of anything that is.

##### Should containers auto-update?

Not on a server you care about. Automatic image updates mean an unattended major version change at an hour nobody is watching. Pin a minor tag, pull deliberately, and keep the previous tag long enough to roll back.

Docker needs a kernel of your own, which is what a RE:NODE VDS is: full root access on an Intel i9-9900K in Germany, NVMe on a ZFS pool, from 8 GB and two vCPU up to 24 GB and six. Every one is prepared by hand rather than scripted, so it arrives within 24 hours rather than in a minute.

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

---

### Fail2ban on a VDS: jails, bans and testing

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/fail2ban-guide

Fail2ban watches log files, and when the same address fails to authenticate too many times in a row it adds a firewall rule that drops it for a while. That is the whole product. Twenty minutes of setup removes most of the noise from your authentication log and a measurable amount of load from a small server, and it is one of the four or five things worth doing on the first day of a new VDS.

It is also routinely oversold. Fail2ban cannot stop a distributed attack where every attempt comes from a different address, it cannot stop a volumetric flood, and if you have already disabled password authentication on SSH then the brute-force attempts it blocks were never going to succeed anyway. Install it because it keeps your logs readable and your CPU free, not because it makes the box secure. The thing that makes SSH secure is turning passwords off, which is a different post: [SSH keys and hardening](https://www.renode.space/blog/ssh-keys-and-hardening).

#### How it actually works

Three pieces, and understanding the split makes every problem easier to diagnose.

A **filter** is a set of regular expressions that recognise a failure in a specific log format. They live in `/etc/fail2ban/filter.d/`, one file per service, and the distribution ships dozens of them: `sshd.conf`, `nginx-http-auth.conf`, `postfix.conf`, and so on.

A **jail** ties a filter to a log source and a set of numbers: how many failures, in what window, and how long the ban lasts. Jails are defined in `/etc/fail2ban/jail.conf` and, importantly, overridden by you somewhere else.

An **action** is what happens when the jail fires. The default writes an `iptables` or `nftables` rule that rejects the address on the ports the jail covers. Actions live in `/etc/fail2ban/action.d/`, and there are alternatives that call `ufw`, send mail, or hit an API.

```diagram
title: What happens between a failed login and a ban
attacker = Attacker | repeated logins
sshd = sshd | writes a failure line
journal = Log source | journald or auth.log
f2b = fail2ban | filter plus counter
fw = Firewall rule | iptables or nftables
attacker -> sshd : password attempt
sshd -> journal
f2b -> journal : reads
f2b -> fw : maxretry reached
fw -> attacker : dropped for bantime
```

Two consequences follow from that diagram. Fail2ban is reactive: it reads a line after it has been written, so the failed attempts always happen first, and a ban takes effect a second or two later. And a ban is only ever a firewall rule, so anything that bypasses your firewall also bypasses fail2ban. That last point has a very common shape, covered near the end of this post.

#### Installing it and the only file you should edit

```bash
$ apt update && apt install fail2ban
$ systemctl enable --now fail2ban
$ fail2ban-client status
```

Do not edit `jail.conf`. It is a packaged file, it is replaced on every upgrade, and your changes disappear silently. Create `/etc/fail2ban/jail.local` instead, which is read afterwards and wins. Anything you leave out of `jail.local` falls back to the shipped defaults, so the file only needs to contain what you are changing.

/etc/fail2ban/jail.local:

```ini
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 203.0.113.42

# Ban for longer each time the same address comes back.
bantime.increment = true
bantime.maxtime   = 5w

[sshd]
enabled  = true
mode     = aggressive
maxretry = 4
bantime  = 2h
```

The shipped defaults are `bantime = 10m`, `findtime = 10m` and `maxretry = 5`. Ten minutes is short enough to be nearly decorative against a patient scanner, which is why the first thing most people change is `bantime`.

`bantime.increment` is the setting worth knowing about and the one people miss. With it on, an address that gets itself banned again after the first ban expires is banned for twice as long, then four times, up to `bantime.maxtime`. A single curious visitor is out for an hour; something that keeps coming back for a week ends up out for five. It costs nothing and it does almost all of the useful work.

`ignoreip` is your seatbelt. Put your home or office address in it if you have a static one. If you do not, skip it rather than guessing, and read the unbanning section below instead.

#### The sshd jail, and the log it cannot find

The `sshd` jail is enabled in most distributions' defaults and is the only one that matters on a fresh box. The modern failure is not configuration, it is the log source.

Fail2ban traditionally reads `/var/log/auth.log`, which is written by rsyslog. Recent minimal Debian and Ubuntu images do not install rsyslog at all, and everything goes to the systemd journal instead. On those systems the sshd jail fails to start, and the error in `journalctl -u fail2ban` is about a missing log path rather than about anything you did. The fix is one line:

/etc/fail2ban/jail.local:

```ini
[sshd]
enabled = true
backend = systemd
```

Check which situation you are in before you assume: `ls -l /var/log/auth.log`. If the file exists and is growing, the default file backend is fine. If it does not, set `backend = systemd` and the jail reads the journal directly.

The `mode` parameter on the sshd filter is worth setting. `normal` matches straightforward authentication failures. `aggressive` adds the connection-closed-before-authentication lines and the malformed-packet noise that scanners generate, which catches bots that probe without ever submitting a password. On a server with password authentication already disabled, `aggressive` is the mode that actually bans anything, because a bot trying passwords against a key-only daemon never produces a classic failure line.

> [!TIP]
> Change your SSH port and the jail stops matching, because the default jail covers `port = ssh`, which resolves to 22 from `/etc/services`. Set `port = 2222` explicitly in the jail when you move the daemon, or the ban will be written for a port nobody is attacking.

#### Ban times, find times, and what the numbers mean

Three numbers decide the behaviour and they interact in a way that is easy to get backwards.

| Setting | Default | What it means |
| --- | --- | --- |
| `maxretry` | `5` | Failures needed before a ban |
| `findtime` | `10m` | The window those failures must fall inside |
| `bantime` | `10m` | How long the firewall rule stays |
| `bantime.increment` | off | Multiply the ban each repeat offence |
| `bantime.maxtime` | none | Ceiling for the incremented ban |

`findtime` is a sliding window, not a bucket that resets on a clock. Four failures spread over eleven minutes with a ten-minute `findtime` never triggers anything, because at no point were there four inside a ten-minute span. A slow scanner that tries one password every three minutes is invisible to the defaults, which is a real technique and one reason to widen `findtime` rather than lower `maxretry`.

Sensible starting numbers for a server only you log into: `maxretry = 4`, `findtime = 30m`, `bantime = 1h`, with `bantime.increment` on. If other people use the box, go easier on the first offence and let the increment punish persistence, because the one person guaranteed to trip a strict jail is a colleague with an old key still in their agent.

There is a second-order jail shipped with fail2ban that is worth turning on once the rest works, and it is the one almost nobody enables:

/etc/fail2ban/jail.local:

```ini
[recidive]
enabled  = true
bantime  = 1w
findtime = 1d
maxretry = 5
```

The `recidive` jail reads fail2ban's own log and bans addresses that got themselves banned by any other jail five times in a day. It is the catch-all for anything that comes back after its ban expires, and it covers hosts that rotate slowly between your services. It needs `/var/log/fail2ban.log` to exist; if your install logs to the journal instead, set `backend = systemd` on this jail too.

#### Jails for a web server

If the VDS runs nginx, three shipped jails earn their place. Add them to `jail.local`, adjusting the log paths to match your setup.

/etc/fail2ban/jail.local:

```ini
[nginx-http-auth]
enabled = true
logpath = /var/log/nginx/error.log

[nginx-botsearch]
enabled = true
logpath = /var/log/nginx/access.log

[nginx-limit-req]
enabled = true
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 1m
```

`nginx-http-auth` catches brute force against HTTP basic authentication. `nginx-botsearch` matches requests for the paths scanners always ask for, which on a WordPress site is a steady drip all day. `nginx-limit-req` is the interesting one: it does nothing unless you have configured nginx's own `limit_req` zones, and then it promotes a client that keeps hitting the rate limit from "throttled" to "banned at the firewall", which is much cheaper to serve.

That ordering is the general principle. Fail2ban is the blunt instrument at the end. The application should rate-limit first, because it can tell a real user from a bot in ways a log line cannot, and only the clients that ignore the throttle deserve a firewall rule. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) goes through where each layer belongs.

If you use ufw for the firewall, point fail2ban at it so the two tools are not writing competing rules:

/etc/fail2ban/jail.local:

```ini
[DEFAULT]
banaction = ufw
```

Otherwise fail2ban inserts its own chains ahead of ufw's, which works, but leaves you with two places to look when an address is unexpectedly blocked. The rest of the firewall story is in [the ufw firewall guide](https://www.renode.space/blog/ufw-firewall-guide).

#### Proving it works

An untested fail2ban install is a comfort object. Four checks, in order, and each one answers a different question.

```bash
$ fail2ban-client status                     # which jails are running
$ fail2ban-client status sshd                # counters and current bans
$ fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
$ iptables -S | grep f2b                     # the rules that exist right now
$ nft list ruleset | grep -i f2b             # on nftables systems
```

`fail2ban-client status sshd` prints currently failed, total failed, currently banned, total banned and the list of banned addresses. If total failed is climbing but total banned stays at zero, the filter is matching and the numbers are too loose. If total failed is stuck at zero while `journalctl -u ssh` clearly shows attempts, the filter is not matching the log at all, which is almost always the wrong backend or the wrong `logpath`.

`fail2ban-regex` is the tool that settles it. Point it at a real log and a filter file and it tells you how many lines matched, how many were ignored, and how many it could not parse. Add `--print-all-missed` to see the lines it skipped, which is how you find out that your log format has a timestamp it does not recognise.

To test end to end, fail yourself deliberately from a second machine or a phone tethered on mobile data - not from the address you are currently connected from. Get the password wrong five times, then check the status output from your existing session. Then unban yourself.

```bash
$ fail2ban-client set sshd unbanip 203.0.113.9
$ fail2ban-client unban 203.0.113.9        # every jail at once
$ fail2ban-client unban --all              # clear everything
```

> [!WARNING]
> Locking yourself out is the most common fail2ban incident by a wide margin, and it usually happens while testing. Open a second SSH session and leave it connected before you start. Whether that session survives your own ban depends on the rule order: with ufw, or any ruleset that accepts established connections before fail2ban's chain, it stays up; with a bare iptables setup and a `REJECT` rule at the top, it does not. Do not rely on it. Check that your provider gives you console access to the VDS, and confirm you can reach it before you need it.

#### What fail2ban will not do for you

Say the uncomfortable parts out loud, because a lot of guides do not.

- **Distributed attempts get through.** A botnet with ten thousand addresses making three attempts each never reaches `maxretry` on any single address. Fail2ban is built for the single persistent scanner, which is most of the traffic, but it is not a defence against a patient attacker.
- **It does nothing about volumetric floods.** Packets still arrive, still consume your uplink and still cost the kernel work. Filtering that has to happen upstream of your machine, and [DDoS attacks on game servers explained](https://www.renode.space/blog/ddos-attacks-on-game-servers-explained) covers what can and cannot be done about it.
- **Docker publishes ports past it.** This is the big one. A container started with `-p 5432:5432` is reached through the `DOCKER-USER` and `FORWARD` chains, and fail2ban's default rules live in `INPUT`, so the ban has no effect on anything in a container. Either bind the container to localhost with `-p 127.0.0.1:5432:5432`, or tell the action to write into the right chain with `banaction = iptables-allports[chain=DOCKER-USER]`. [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) has the rest of that caveat.
- **It cannot help a weak password.** Five attempts is plenty if the password is `admin123`. Fail2ban raises the cost of guessing; it does not raise the cost enough to matter against a genuinely bad secret.
- **It is not a substitute for turning passwords off.** With `PasswordAuthentication no` and key-only access, the brute force it blocks was already failing. That is still worth doing for the log noise and the CPU, but be honest about the order of operations.

If something has already gone wrong rather than been prevented, fail2ban is not the tool you want - [what to do when your server is hacked](https://www.renode.space/blog/what-to-do-when-your-server-is-hacked) is.

#### Day-to-day operation

After any change to `jail.local`, reload rather than restart, so existing bans survive:

```bash
$ fail2ban-client reload          # all jails
$ fail2ban-client reload sshd     # one jail
$ fail2ban-client get sshd bantime
$ fail2ban-client set sshd banip 198.51.100.7
$ journalctl -u fail2ban -n 50 --no-pager
```

Bans are persisted in a small SQLite database at `/var/lib/fail2ban/fail2ban.sqlite3`, so they survive a restart of the service and a reboot of the machine. That is also where the increment history lives, which is why a repeat offender keeps its escalating ban across reboots.

Once a month, glance at `fail2ban-client status sshd` and at the size of your authentication log. A sudden change in either direction is information: a log that has gone quiet usually means rsyslog stopped or the jail broke, not that the internet became polite. The wider habit of deciding which logs to keep and for how long is in [logs worth keeping](https://www.renode.space/blog/logs-worth-keeping), and the commands for reading them quickly are in [Linux commands for server admins](https://www.renode.space/blog/linux-commands-for-server-admins).

On a managed RE:NODE plan you do not install fail2ban, because there is no host shell to install it on. The equivalent protections are built in: the panel puts a captcha and rate limits on login, stores password hashes with bcrypt, offers two-factor with authenticator codes and single-use recovery codes, keeps a session list you can sign out of, and lets API keys be restricted by address. Upstream filtering drops obvious volumetric floods before they reach the node. On a VDS all of that is yours to assemble, and fail2ban is one brick of it.

#### FAQ

##### Does fail2ban slow the server down?

Not noticeably. It is a Python process reading log lines, typically using a few tens of megabytes of memory and almost no CPU. The bans themselves are firewall rules, which the kernel evaluates in microseconds. A jail with an enormous ban list and a very short `findtime` on a busy web log is the only case where it shows up at all.

##### Why is my sshd jail reporting zero failures?

Almost always the log source. Check whether `/var/log/auth.log` exists; on images without rsyslog it does not, and you need `backend = systemd` in the jail. Confirm with `fail2ban-regex` against a real log, which tells you exactly how many lines it matched.

##### Will it ban me if I mistype my password?

Yes, and it should. That is why `ignoreip` exists for a static address, why you keep a second session open while changing anything, and why you check that your provider's console access works before you need it. Use keys and the question stops arising.

##### Is fail2ban enough on its own?

No. It is one layer on top of key-only SSH, a default-deny firewall, current packages and unprivileged service users. It removes noise and stops the lazy attacks. Every serious control is somewhere else, and [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds) lists them in order.

##### Can it protect a game server port?

Only if the game writes a parsable log of failed attempts, which most do not, and only for connection-based abuse rather than floods. Banning by address is also a poor fit for players, who share addresses and change them. Use the game's own ban list and RCON for players, and keep fail2ban for administrative interfaces.

##### How do I see every address currently banned?

`fail2ban-client status <jail>` prints the list for one jail. Recent versions also have `fail2ban-client banned`, which prints every jail's list at once. To see what the kernel actually enforces, `iptables -S | grep f2b` or `nft list ruleset`.

A RE:NODE VDS gives you full root access on Intel i9-9900K hardware in Germany, prepared by hand and delivered within 24 hours, so fail2ban, the firewall and the update schedule are all yours to run. Managed plans instead come with the panel's own login rate limits, two-factor and per-server access controls, with nothing to install.

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

---

### First hour on a new VDS: a setup checklist

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/first-hour-on-a-new-vds

A fresh VDS is a machine with root access, a public address, and no opinions. Within minutes of it coming up, automated scanners will be trying passwords against port 22, and everything else you do to it assumes a base that is patched, has a user who is not root, and answers only on the ports you chose. That base takes about twenty minutes. The hour in the title is because you should read what you are typing, and because rushing this part is how people end up rebuilding the machine in a fortnight.

The order below matters. Updates come before configuration, because the packages you are about to configure are the ones being replaced. The user comes before the SSH lockdown, because locking root out before you have another way in is the classic way to lose a server on day one. The firewall comes after SSH, because a default-deny rule with no exception for port 22 ends the session and the conversation.

Commands are for Debian and Ubuntu, which is what most VDS images are. On RHEL-family systems substitute `dnf` for `apt`, `wheel` for `sudo`, and `firewalld` for `ufw`; the shape does not change.

#### The first login, and what to check

You will have been sent an address and either a root password or a key. Log in and read the fingerprint prompt rather than typing yes reflexively - it is the one chance you get to confirm you are talking to your machine. Compare it with the fingerprint shown in your provider's console if they publish one.

```bash
$ ssh root@203.0.113.10
The authenticity of host '203.0.113.10' can't be established.
ED25519 key fingerprint is SHA256:5xM8n...
Are you sure you want to continue connecting (yes/no/[fingerprint])?
```

Then spend two minutes finding out what you actually have. This is worth doing before you install anything, because it is the only time the machine is in a known state:

```bash
$ cat /etc/os-release          # distribution and version
$ uname -r                     # kernel
$ lscpu | head -20             # cores, model, virtualisation flags
$ free -h                      # memory, and whether swap exists
$ df -h /                      # disk, and how much the image used
$ ip -brief address            # addresses, v4 and v6
$ ss -ltnp                     # what is already listening
$ systemd-detect-virt          # kvm, lxc, none
```

`ss -ltnp` is the interesting one. A clean image usually shows only `sshd`, and sometimes a DHCP client or a mail transfer agent listening on localhost. Anything else listening on `0.0.0.0` is something you did not ask for and should look into. `systemd-detect-virt` tells you whether you are on full virtualisation or sharing a kernel, which decides whether some later steps are even possible - [VPS, VDS or dedicated server](https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server) explains why that distinction matters more than the marketing does.

If the root password arrived by email, treat it as already public and plan to disable password login entirely within the next twenty minutes. That is step four.

#### Update everything, then reboot

An image is a snapshot from whenever it was built, which can be months ago. Before anything else:

```bash
$ apt update
$ apt full-upgrade -y
$ apt autoremove --purge -y
```

`full-upgrade` rather than `upgrade`, because `upgrade` refuses to remove packages and therefore quietly skips updates that need a dependency change. On a fresh image that difference is usually the kernel.

Then check whether a reboot is needed, and do it now rather than at an inconvenient moment later:

```bash
$ [ -f /var/run/reboot-required ] && cat /var/run/reboot-required
$ reboot
```

A kernel update does nothing until the machine restarts on the new one. A brand new VDS with nothing running is the cheapest reboot you will ever take, so take it. Log back in and confirm with `uname -r` that the version changed.

#### A user that is not root

Working as root all day means every typo is executed with full authority and every process you start inherits it. Make an ordinary account with the ability to escalate deliberately:

```bash
$ adduser deploy
$ usermod -aG sudo deploy
```

`adduser` is the interactive Debian wrapper: it creates the home directory, sets the shell and prompts for a password. Give it a real password even if you intend to log in with keys, because `sudo` will ask for it.

Now give that user your SSH key. The simplest reliable way, if root already has your key installed, is to copy the whole directory with the ownership fixed in one step:

```bash
$ rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
$ chmod 700 /home/deploy/.ssh
$ chmod 600 /home/deploy/.ssh/authorized_keys
```

Those permissions are not decoration. `sshd` refuses to use an `authorized_keys` file that is writable by anyone but its owner, and it does so silently from the client's point of view, which produces the "it keeps asking for a password" mystery.

Open a second terminal and test before you change anything else:

```bash
$ ssh deploy@203.0.113.10
$ sudo -v
```

Keep that second session open for the rest of this post. If a configuration change breaks logins, the session you already have is what saves you.

#### SSH keys, and closing the front door

If you do not have a key pair yet, make one on your own machine, not on the server:

```bash
$ ssh-keygen -t ed25519 -C "deploy@laptop"
$ ssh-copy-id deploy@203.0.113.10
```

Use `ed25519`. It is smaller and faster than RSA, and every SSH implementation from the last decade supports it. Give the private key a passphrase and let your agent hold it unlocked for the working day.

Then turn off the two things that make a server guessable. On modern Debian and Ubuntu, `/etc/ssh/sshd_config` begins with an `Include /etc/ssh/sshd_config.d/*.conf` line, and this detail catches almost everyone: **sshd uses the first value it obtains for a keyword**, so anything in that directory wins over the main file below it. Cloud images frequently ship a file there that turns password authentication back on.

```bash
$ ls -la /etc/ssh/sshd_config.d/
$ grep -r PasswordAuthentication /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
```

Write your own file, named so that it sorts before anything already there:

/etc/ssh/sshd_config.d/10-hardening.conf:

```text
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 20
AllowUsers deploy
```

Check the syntax before you restart, because a bad config file means `sshd` does not come back:

```bash
$ sshd -t && systemctl restart ssh
```

Now, from a third terminal, prove that a new login still works and that a password login is refused. Only then close the root session.

> [!WARNING]
> On Ubuntu 24.04 the SSH daemon is socket-activated, so the listening port comes from `ssh.socket` rather than the `Port` line in `sshd_config`. Check with `systemctl status ssh.socket` before you conclude that changing the port did nothing. Moving SSH off 22 is cosmetic anyway: it quiets the logs and stops nothing that is aimed at you.

[SSH keys and hardening](https://www.renode.space/blog/ssh-keys-and-hardening) covers the rest of this properly: agents, jump hosts, per-key restrictions and what to do when you do lock yourself out.

#### The firewall: default deny

Default deny inbound, allow outbound, then open exactly what you need. On Ubuntu that is `ufw`, and the order of the commands matters enormously:

```bash
$ ufw default deny incoming
$ ufw default allow outgoing
$ ufw allow OpenSSH
$ ufw enable
$ ufw status numbered
```

`ufw allow OpenSSH` before `ufw enable`, every time. Enabling a default-deny policy without an SSH rule disconnects you immediately and the only way back is your provider's console. `ufw` does warn you, and the warning is easy to click past.

Then the services you are actually going to run:

```bash
$ ufw allow 80/tcp
$ ufw allow 443/tcp
$ ufw allow 27015:27020/udp      # a range for game servers
$ ufw allow from 10.0.0.0/24 to any port 5432 proto tcp
```

That last form is the one worth learning. A database, a metrics endpoint or an admin panel should be reachable from named addresses, not from everywhere. [Firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter) has the reasoning, and [the ufw guide](https://www.renode.space/blog/ufw-firewall-guide) has the syntax in full.

One trap: **Docker publishes ports by writing its own iptables rules, and those bypass ufw's rules entirely.** A container started with `-p 5432:5432` is open to the internet even with a tidy-looking `ufw status`. Either bind the publish to localhost with `-p 127.0.0.1:5432:5432`, or add your rules to the `DOCKER-USER` chain. [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) covers it in context.

#### Time, hostname and the small settings

Wrong clocks produce wrong logs, failed TLS handshakes and expired tokens, and they are invisible until they are not.

```bash
$ timedatectl set-timezone Etc/UTC
$ timedatectl set-ntp true
$ timedatectl
```

Use UTC on servers. You will eventually correlate a log on this machine with a log on another one, and the version of that job where everything is already in UTC is the pleasant version.

Give the machine a name that means something, especially if there will be a second one:

```bash
$ hostnamectl set-hostname web-01
```

Add that name to `/etc/hosts` beside `127.0.1.1`, or `sudo` will pause for a second on every command while it tries to resolve the host name and fails.

Two more small things worth doing while you are here. Set the locale (`update-locale LANG=en_GB.UTF-8`) so scripted tools stop printing locale warnings, and cap the journal so logs cannot fill the disk:

/etc/systemd/journald.conf:

```ini
[Journal]
SystemMaxUse=500M
SystemMaxFileSize=50M
```

A full root filesystem takes a server down as effectively as anything an attacker could do, and an uncapped journal on a chatty service is the most common cause. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) covers what to retain.

#### Automatic security updates

A server nobody patches is a server that will be broken into eventually. Automatic security updates are the highest-value twenty seconds in this list.

```bash
$ apt install unattended-upgrades
$ dpkg-reconfigure --priority=low unattended-upgrades
```

That writes `/etc/apt/apt.conf.d/20auto-upgrades`. Then open `/etc/apt/apt.conf.d/50unattended-upgrades` and decide two things: whether the machine may reboot itself, and when.

/etc/apt/apt.conf.d/50unattended-upgrades:

```text
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
```

Automatic reboots are right for a web server and wrong for a game server with people on it at four in the morning. If you turn them off, you have taken on the job of rebooting after kernel updates yourself, and `/var/run/reboot-required` is what to check. Verify the whole thing works rather than assuming:

```bash
$ unattended-upgrade --dry-run --debug
$ cat /var/log/unattended-upgrades/unattended-upgrades.log
```

By default this applies security updates only, which is the right scope. Widening it to all updates on a production machine trades a small risk of being exploited for a larger risk of a package changing behaviour while you sleep.

#### Swap, and the memory ceiling

Most small VDS images ship with no swap at all. Without it, a memory spike does not slow down, it kills something: the kernel's out-of-memory killer picks the largest process, which is usually the exact thing you are running the server for.

A modest swap file is insurance, not extra memory. Two gigabytes is plenty on an 8 GB machine.

```bash
$ fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
$ chmod 600 /swapfile
$ mkswap /swapfile
$ swapon /swapfile
$ swapon --show
```

Make it survive a reboot by adding one line to `/etc/fstab`:

/etc/fstab:

```text
/swapfile none swap sw 0 0
```

Then lower the kernel's willingness to use it, so it stays a safety net rather than becoming the normal state:

```bash
$ echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
$ sysctl --system
```

`fallocate` fails on some filesystems, which is why the `dd` fallback is there. And if a server is genuinely swapping during normal operation, swap is not the fix - the machine is too small, or something is leaking. [Linux swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer) covers reading the evidence, and [CPU vs RAM for game servers](https://www.renode.space/blog/cpu-vs-ram-for-game-servers) covers which one you are actually short of.

#### What can wait until hour two

You are done with the part that has to happen before the machine does any work. The next layer, in the order most people need it:

| Next | Why |
| --- | --- |
| A backup or snapshot, taken now | A clean baseline costs nothing and is worth a lot later |
| `fail2ban` | Repeated failures start costing the attacker something |
| A reverse proxy and TLS | Anything serving HTTP wants nginx and a certificate |
| `systemd` units for your services | Restart on failure, start on boot, logs in one place |
| Monitoring | Disk, memory and whether the thing is still answering |
| Docker or Compose | Only if the workload is genuinely several services |

[fail2ban](https://www.renode.space/blog/fail2ban-guide), [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) and [nginx as a reverse proxy](https://www.renode.space/blog/nginx-reverse-proxy-guide) each take one of those properly. If the plan is several game servers on one box, [multiple game servers on one VDS](https://www.renode.space/blog/multiple-game-servers-on-one-vds) covers ports, users and process supervision before you get into a mess with them.

What not to do in the first hour: install a control panel you have not decided on, open ports for services that do not exist yet, or follow a hardening guide that changes forty `sysctl` values you cannot explain. Every change you cannot explain is a change you will not think to undo when something breaks.

A VDS is a machine you own the whole of, including the maintenance. If that list reads like work you would rather not do monthly, the honest comparison is in [VDS or a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel), and the answer is genuinely different for different people.

#### FAQ

##### Which distribution should I pick?

Whichever long-term-support release you already know. Ubuntu LTS and Debian stable both get security updates for years, have the largest amount of accurate documentation written about them, and are what most install instructions assume. A newer, shorter-lived release buys you package versions you probably do not need in exchange for an upgrade every nine months.

##### Do I really need a non-root user if I am the only person using the machine?

Yes, and the reason is not other people. It is that running as root makes every mistake maximal, every compromised process a root process, and every copied-and-pasted command from the internet a root command. Typing `sudo` deliberately is a half-second pause in front of the destructive ones.

##### Is changing the SSH port worth it?

It removes most of the noise from your authentication logs, which makes real events easier to see. It does not add security against anyone aiming at you specifically, because a port scan finds the new port in seconds. Do it for the quiet logs if you like, but never instead of keys and a firewall.

##### Should I install fail2ban straight away?

It is useful, and it is second-order. With password authentication disabled, brute force against SSH cannot succeed regardless of how many attempts are made, so fail2ban is mostly saving you log volume there. It earns its place in front of web logins, mail and anything else that still accepts a password.

##### How much swap should I add?

Enough to absorb a spike, not enough to hide a problem. One to two gigabytes on a machine with 8 GB or more, and `vm.swappiness` set low. Servers that need swap proportional to their memory are a rule from an era of much smaller machines and hibernation.

##### What is the one step people skip and regret?

Testing the second login before closing the first session. Every lockout story starts with someone restarting `sshd` after a config change, closing the terminal, and discovering that `AllowUsers` had a typo in it. Keep the working session open until a fresh one has succeeded.

RE:NODE VDS plans run on Intel i9-9900K hardware in Germany with NVMe storage and full root access, so every command in this post is yours to run. Each one is prepared by hand rather than stamped out by a script, and is ready within 24 hours of the order.

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

---

### Linux commands for server admins: top 40

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/linux-commands-for-server-admins

Administering a server is mostly four questions asked in a loop: what is running, what is it doing to the machine, what did it write in the log, and who can reach it. Around forty commands answer those questions, and once they are in your fingers you will go months without needing a forty-first. This post is those commands, with the flags that make them useful and the traps that make them expensive. It assumes Debian or Ubuntu, because that is what most VDS images are, but everything here except `apt` works the same on any modern distribution.

Two habits are worth building before any of it. First, `man ls` and `ls --help` are faster than a search engine and they describe the version you actually have, which is not always the version in the tutorial you found. Second, press `Ctrl-r` and start typing to search backwards through your shell history. Most of what you need to run today, you ran last week.

#### The daily ten

These are the ones you type without thinking about them. Learn the flags in the right-hand column and you have covered most of a working day.

| Command | Worth knowing |
| --- | --- |
| `ls` | `ls -lah` for long, hidden and human-readable sizes |
| `cd` | `cd -` returns to the previous directory |
| `pwd` | Prints where you are, useful inside scripts |
| `cat` | Only for short files; use `less` for anything longer than a screen |
| `less` | `/` searches, `G` jumps to the end, `q` quits, `-S` stops wrapping |
| `cp` | `cp -a` preserves permissions, ownership and timestamps |
| `mv` | Rename and move are the same operation |
| `rm` | `rm -rf` has no undo and no recycle bin |
| `mkdir` | `mkdir -p a/b/c` creates the whole path |
| `nano` | The editor that is already installed and does not need a tutorial |

A few small ones sit alongside them. `stat file` prints permissions, ownership, size and all three timestamps, which settles arguments about whether a config was really edited. `file something` tells you what a file actually is rather than what its extension claims, which is how you discover that the "zip" somebody uploaded is a RAR archive. `nproc` prints the number of CPUs the machine believes it has, and you need that number to interpret load averages later in this post.

> [!WARNING]
> `rm -rf` on a path built from a variable is the classic way to delete a server. If the variable is empty, `rm -rf $DIR/` becomes `rm -rf /`. GNU coreutils refuses that exact case, but `rm -rf $DIR/*` with an empty `DIR` is not refused, and it clears the root directory quite happily. Echo the command before you run it when you are constructing paths.

#### Reading files and logs

Most of the work is reading. The trick is not reading everything.

```bash
$ tail -n 200 /var/log/nginx/error.log      # last 200 lines
$ tail -F /var/log/nginx/error.log          # follow, survives rotation
$ head -n 40 /etc/nginx/nginx.conf          # first 40 lines
$ less +G /var/log/syslog                   # open at the end
$ wc -l /var/log/auth.log                   # how many lines is this
```

`tail -f` follows a file by its open handle. When log rotation renames the file underneath you, `-f` keeps watching a file nobody writes to any more and you sit there believing the server has gone quiet. `tail -F` reopens by name and survives the rotation. Use the capital.

For anything managed by systemd, which on a modern box is nearly everything, the log is not a file at all:

```bash
$ journalctl -u nginx -f                    # follow one unit
$ journalctl -u minecraft --since "30 min ago"
$ journalctl -b -p err                      # errors since this boot
$ journalctl -k                             # kernel messages only
$ journalctl --disk-usage
$ journalctl --vacuum-size=200M             # when the journal has grown
```

`-u` selects a unit, `-b` limits to the current boot, `-p err` filters by priority, and `--since` takes plain English like `"yesterday"` or `"2026-09-20 18:00"`. The combination that solves most incidents is `journalctl -u <name> -b --no-pager | less`, then search inside `less`. If the service is a game server, the interesting lines are usually the last thirty before it stopped, not the stack trace at the end. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) covers which of these are worth shipping somewhere permanent.

Compressed archives are searchable without unpacking them. `zcat`, `zless` and `zgrep` behave like their plain versions on `.gz` files, so `zgrep "Invalid user" /var/log/auth.log.*.gz` works across a fortnight of rotated logs in one go.

#### Finding things: find, grep and which

`grep` searches inside files. `find` searches for files. People reach for the wrong one constantly.

```bash
$ grep -rn "listen" /etc/nginx/            # recursive, with line numbers
$ grep -i "error" app.log | tail -n 50     # case-insensitive
$ grep -c "404" access.log                 # count instead of printing
$ grep -v "healthcheck" access.log         # everything except
$ grep -B2 -A5 "Exception" app.log         # with context around each hit
```

`-rn` is the pair to memorise: recursive and with line numbers, so you get `file:line: text` and can open the file straight at the right place. `-B` and `-A` print lines before and after the match, which is the difference between knowing an exception happened and knowing what request caused it.

```bash
$ find /home -type f -name "*.db" -size +50M
$ find /var/log -type f -name "*.gz" -mtime +14 -delete
$ find . -type d -name node_modules -prune -o -type f -name "*.js" -print
$ which python3          # which binary will run
$ command -v node        # the portable version of the same question
```

`find` reads as a sentence: where to look, what type, what name, and then what to do about it. `-mtime +14` means modified more than fourteen days ago. `-delete` is final, so run the command once without it and read the list first. That two-step is not caution theatre; `find` with a slightly wrong `-name` pattern will match more than you expected roughly once per year.

`which` answers "what will actually run when I type this", which matters when you have installed a second Node or Python and the old one is still first in `PATH`. `echo $PATH` when the answer surprises you.

#### Is the box busy: processes, memory and CPU

Four commands tell you whether the machine is in trouble, and in what way.

```bash
$ uptime
 14:22:01 up 12 days,  3:41,  1 user,  load average: 1.82, 1.44, 0.98
$ free -h
$ ps aux --sort=-%mem | head -n 12
$ top          # or htop, if you installed it
```

The load average is three numbers: the last one, five and fifteen minutes. Compare them against `nproc`. On a two-vCPU box, a load of 2.00 means fully busy, 4.00 means work is queuing, and 0.8 means fine. The direction matters as much as the value: `0.5 1.4 2.1` is a machine recovering, and `2.1 1.4 0.5` is a machine that is about to have a bad afternoon. Load counts processes waiting on disk as well as on CPU, so a high load with idle CPUs usually means storage, not compute.

`free -h` prints a row you should mostly ignore and a column you should not. The `free` column is almost always small, because Linux uses spare memory as disk cache and gives it back on demand. The number that tells you whether you have memory is `available`. If `available` is under a few hundred megabytes, you are one bad allocation away from the kernel killing something, which is a topic of its own in [swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer).

`ps aux --sort=-%mem | head` is the fastest answer to "what is eating the RAM". Swap `%mem` for `%cpu` to ask the other question. `top` is the live version; `htop` is the same thing with colours, scrolling and a tree view, and is worth the thirty seconds it takes to install. Inside `top`, press `M` to sort by memory, `P` for CPU, and `1` to expand the per-core display.

When something has to stop:

```bash
$ kill 4821             # polite: SIGTERM, lets it clean up and save
$ kill -9 4821          # SIGKILL: immediate, no save, last resort
$ pkill -f "valheim_server"
$ pgrep -af java        # find the PID and the full command line first
```

The distinction is not academic for game servers. A `SIGTERM` gives most of them the chance to write the world to disk; a `SIGKILL` throws away everything since the last autosave. Reach for `-9` only after a polite stop has failed for a minute.

#### Disk space and what filled it

A full disk breaks things in unhelpful ways: the database refuses writes, the log stops, and the service dies with an error about something unrelated. Check space before you debug anything strange.

```bash
$ df -h                        # space per filesystem
$ df -i                        # inodes, when df -h says there is room
$ du -sh /var/* 2>/dev/null | sort -h
$ du -sh * | sort -h           # then descend into the biggest one
$ ncdu /var                    # interactive, if you install it
$ lsof +L1                     # deleted files still held open
```

`du -sh * | sort -h` and then `cd` into the largest result is the whole technique. Three or four repetitions land you on the directory that matters. `sort -h` sorts human-readable sizes properly, so 2G ranks above 900M rather than below it alphabetically.

Two failure modes look like magic until you have seen them. The first is inode exhaustion: `df -h` shows 40% used, writes still fail, and `df -i` shows 100% of inodes gone, usually to a session directory or a cache holding millions of tiny files. The second is a deleted file that a process still has open. The space is not returned until the file handle closes, so `du` and `df` disagree with each other. `lsof +L1` lists exactly those files; restarting the process that holds them frees the space instantly. This is almost always a log file somebody deleted instead of truncating. The right way to empty a log that is in use is `truncate -s 0 /path/to/file`, not `rm`.

#### Services and systemd

Everything that should survive a reboot belongs to systemd. Five subcommands cover the job.

```bash
$ systemctl status nginx
$ systemctl restart nginx
$ systemctl enable --now fail2ban      # start it and start it at boot
$ systemctl disable --now something    # stop it and stop it at boot
$ systemctl daemon-reload              # after editing any unit file
$ systemctl list-units --type=service --state=running
$ systemctl list-timers
$ systemd-analyze blame                # what made this boot slow
```

`enable` and `start` are separate ideas and mixing them up is the single most common systemd mistake. `start` runs it now. `enable` makes it run at boot. A service you started but never enabled disappears the first time the box reboots, usually months later, and nobody connects the two events. `--now` does both.

`systemctl status` prints the last ten log lines under the state, which is often enough to see why a start failed. When it is not, `journalctl -u <name> -n 100 --no-pager` gives you the rest. After editing a unit file you must run `daemon-reload` before `restart`, or systemd keeps using the version it parsed at boot and you debug a change that was never applied. Writing the unit files themselves is a longer subject, covered in [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps).

#### Users, permissions and ownership

Permission errors account for a large share of "it works on my machine". The fix is nearly always ownership, not mode.

```bash
$ id                                   # who am I, and in what groups
$ adduser deploy                       # interactive, creates the home dir
$ usermod -aG sudo deploy              # note the -a; without it you replace groups
$ sudo -u minecraft -i                 # become a service user for one session
$ chown -R minecraft:minecraft /srv/minecraft
$ chmod 700 ~/.ssh
$ chmod 600 ~/.ssh/authorized_keys
$ chmod +x start.sh
```

The `-a` in `usermod -aG` is append. Leaving it out replaces every group the user is in with the one you named, which is how people remove their own sudo access. Type it slowly.

Numeric modes are three digits for owner, group and everyone else, where read is 4, write is 2 and execute is 1. So `644` is owner read and write, everyone else read; `755` adds execute for directories and scripts; `600` is owner only. SSH refuses to use a private key or an `authorized_keys` file that is readable by anyone else, silently falling back to a password prompt, and that is the single most common reason a key "does not work" - see [SSH keys and hardening](https://www.renode.space/blog/ssh-keys-and-hardening) for the rest of that story.

Run services as their own unprivileged user, one per service, with the files owned by that user. It costs a minute at setup and it is the difference between one compromised game server and a compromised machine. When you need to act as that user, `sudo -u minecraft -i` gives you a login shell as them rather than making you log out.

#### Networking: what is listening, what can reach it

`netstat` is deprecated and missing from minimal images. `ss` replaced it and is faster.

```bash
$ ss -tulpen                  # every TCP and UDP listener, with the process
$ ss -tn state established    # current connections
$ ip -brief addr              # the machine's addresses, one line each
$ ip route                    # where traffic goes by default
$ ufw status numbered         # what the firewall allows
```

`ss -tulpen` is the most valuable single command in this post. Run it on the day you finish setting up a server and again once a month, and keep the output short. Every line is a door. A database bound to `0.0.0.0` instead of `127.0.0.1` is the second most common way small servers are compromised, and this command is how you find it in five seconds. What to do about the doors you find belongs to [the ufw firewall guide](https://www.renode.space/blog/ufw-firewall-guide) and [firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter).

For testing from outside the box:

```bash
$ curl -I https://example.com                 # response headers only
$ curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" https://example.com
$ dig +short A example.com @1.1.1.1           # ask a specific resolver
$ ping -c 4 203.0.113.10
$ mtr -rwc 50 203.0.113.10                    # 50 packets, report mode
$ nc -zv 203.0.113.10 25565                   # is this TCP port open
```

Run `dig` against a public resolver with `@1.1.1.1` when you have just changed a record, because your own resolver is probably still holding the old answer until its TTL expires. `nc -zv` tests TCP; it cannot meaningfully test a UDP game port, because there is nothing to hand back a refusal. Reading an `mtr` report properly is its own skill, and [latency, jitter and packet loss](https://www.renode.space/blog/latency-jitter-and-packet-loss) explains which column actually matters.

#### Transfers, archives and staying attached

Getting files on and off the machine, and keeping things running after you close the terminal.

```bash
$ scp world.zip deploy@203.0.113.10:/srv/       # one file, quick
$ rsync -avh --progress /srv/world/ backup:/srv/world/
$ rsync -avh --delete src/ dest/                # make dest match src exactly
$ tar -czf world-2026-09-21.tar.gz worlds_local/
$ tar -xzf archive.tar.gz -C /srv/target/
$ unzip plugins.zip -d plugins/
```

`rsync` beats `scp` for anything repeated, because it copies only what changed and can resume. The trailing slash rule is the part that catches everybody: `rsync -a src/ dest/` copies the contents of `src` into `dest`, while `rsync -a src dest/` creates `dest/src`. Test with `--dry-run` the first time, especially with `--delete`, which will remove anything on the destination that is not on the source.

```bash
$ tmux new -s valheim         # new named session
  Ctrl-b then d               # detach, leaving it running
$ tmux attach -t valheim      # come back later
$ tmux ls                     # what sessions exist
$ watch -n 5 'ss -tn state established | wc -l'
```

`tmux` keeps a process alive after your SSH session drops, and lets you reattach to the same terminal from anywhere. It is the right tool for a long compile or a migration you are babysitting. It is the wrong tool for running a game server permanently: a process in `tmux` does not start at boot, does not restart when it crashes, and is invisible to any monitoring you set up later. Use a systemd unit for anything that is meant to be always on, and keep `tmux` for work you are watching.

Finally, keeping the system itself current:

```bash
$ apt update && apt full-upgrade -y
$ apt list --upgradable
$ apt autoremove --purge
$ needrestart                  # which services are running old libraries
```

`apt update` refreshes the package lists and upgrades nothing, which trips up people arriving from other package managers. `full-upgrade` is `upgrade` plus the right to remove a package when an upgrade requires it, which is what you want on a server you actually intend to keep patched. After a library upgrade, the running processes still hold the old copy in memory until they restart; `needrestart` tells you which ones. The whole first-day routine, in order, is in [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds).

On a managed RE:NODE plan none of this applies, because you do not get a shell on the host. What you get instead is an unfiltered console with command history and tab completion, which is the game or app process's own input and output rather than a Linux prompt, plus an in-browser file manager and per-server SFTP credentials. That covers configuration, uploads and log reading without any of the commands above. A VDS is the other trade: full root access, and every command on this page becomes your responsibility. [Choosing between a VDS and a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) puts numbers on which one is cheaper for your case.

#### FAQ

##### What is the difference between `apt` and `apt-get`?

`apt` is the newer front end intended for people typing at a terminal, with a progress bar and slightly friendlier output. `apt-get` has a stable interface intended for scripts. For interactive use they do the same things; use `apt` and do not think about it again.

##### Why does `free -h` say I have almost no free memory?

Because Linux uses otherwise idle memory as a disk cache, and that is a good thing. Cached memory is handed back the moment a program asks for it. Read the `available` column, not `free`, and only worry when `available` gets small.

##### How do I keep a program running after I log out?

For anything permanent, a systemd unit with `Restart=on-failure`. For a one-off task you are supervising, `tmux` or `nohup command &`. The difference is that systemd restarts the program after a crash and starts it again after a reboot, and `tmux` does neither.

##### How do I find which process is using a port?

`ss -tulpen` lists every listener with the process name and PID in the last column. If the port is in use but nothing appears, you are probably not root - run it with `sudo`, because the process column is hidden for other users' processes.

##### Do I need to learn `vim`?

No. `nano` is installed on nearly every distribution, shows its shortcuts at the bottom of the screen, and edits a config file perfectly well. Learn enough `vim` to quit it - `Esc` then `:q!` - because it is sometimes the default editor, and leave the rest until you want it.

##### What should I check first when a server feels slow?

In this order: `uptime` for load against `nproc`, `free -h` for the `available` column, `df -h` for a full disk, then `ps aux --sort=-%cpu | head`. Those four take twenty seconds together and identify the cause most of the time.

A RE:NODE VDS is full root access on Intel i9-9900K hardware in Germany, with NVMe storage, prepared by hand and delivered within 24 hours. Every command on this page is yours to run, and yours to get right.

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

---

### Linux swap and the OOM killer: what to set

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/linux-swap-and-the-oom-killer

Swap is not extra memory. It is a place for the kernel to put pages it does not think you are using, so that the memory they occupied can be used for something else. When it works you never notice it. When the machine genuinely runs out of memory, swap does not save you; it slows down the moment at which you find out, and the process that dies is chosen by the kernel rather than by you.

Two things go wrong on small servers. The first is having no swap at all, which makes the out-of-memory killer arrive without warning. The second is having a lot of swap and a workload that touches all of its memory, which turns a fast machine into an unusable one without anything appearing to crash. This post covers how much swap to give a VDS, which knobs are real, how the OOM killer decides, and how to read the log line it leaves behind so that you fix the right thing.

#### What swap is actually for

Linux divides memory in use into two kinds. **Page cache** is file-backed: copies of things on disk, held in memory because reading them again is free. It can be dropped at any moment, which is why `free -h` shows almost no free memory on a healthy server and why that is not a problem. **Anonymous memory** is everything else: heaps, stacks, the actual working data of your processes. It is not backed by a file, so it cannot simply be dropped. If the kernel wants to reclaim it, it needs somewhere to put it, and that somewhere is swap.

So swap buys you one specific thing: the ability to evict cold anonymous memory. That matters more than it sounds. A long-running server accumulates memory that is allocated and then never touched again - initialisation buffers, a library loaded for a feature nobody uses, a logging path taken once at startup. On a machine with no swap, every byte of that sits in RAM for months. With a small amount of swap, the kernel quietly moves it out and uses the space for page cache, and your disk reads get faster.

What swap does not buy you is capacity. If your processes genuinely need 10 GB of live working set on an 8 GB machine, swap turns a fast out-of-memory failure into a slow, grinding one. That is usually worse, because a crash is obvious and a machine running at a tenth of its speed is not.

| Situation | With no swap | With 2 GB of swap |
| --- | --- | --- |
| Cold pages from startup | Held in RAM forever | Evicted, RAM reused for cache |
| A brief allocation spike | OOM kill | Absorbed, slight slowdown |
| Working set larger than RAM | Fast OOM kill | Thrashing, then OOM kill anyway |
| Memory leak over days | Kill at a predictable point | Kill later, after hours of slow |

#### How much swap, and how to add it

The old rule of twice your RAM came from an era of hibernation and 256 MB machines. For a server that never hibernates, the useful amount of swap is small and roughly fixed.

| RAM | Swap | Reasoning |
| --- | --- | --- |
| 1-2 GB | 1-2 GB | Real headroom on a box this size |
| 4-8 GB | 2 GB | Enough to evict cold pages, not enough to thrash for long |
| 16-24 GB | 2-4 GB | Same job; more would only delay the inevitable |
| Database or JVM workload | 1-2 GB | Deliberately too small to hide a sizing mistake |

Check what you already have. Many VDS images ship with none.

```bash
$ swapon --show
$ free -h
$ cat /proc/swaps
```

A swap file is as fast as a swap partition on any modern kernel and infinitely easier to resize, so use a file:

```bash
$ fallocate -l 2G /swapfile
$ chmod 600 /swapfile
$ mkswap /swapfile
$ swapon /swapfile
$ swapon --show
```

If `fallocate` produces a file the kernel refuses to use, which happens on some filesystems, write it out the slow way with `dd if=/dev/zero of=/swapfile bs=1M count=2048` and repeat from `mkswap`. Then make it survive a reboot:

/etc/fstab:

```text
/swapfile none swap sw 0 0
```

Removing it later is `swapoff /swapfile`, then delete the file and the fstab line. `swapoff` has to read everything back into RAM first, so it fails if there is not enough free memory, and on a busy machine it can take a while.

> [!NOTE]
> On a VDS the swap file lives on the same storage as everything else, so swapping competes with your database and your game server for the same device. NVMe makes this far less painful than it used to be, and still not free - [what NVMe actually changes](https://www.renode.space/blog/what-nvme-actually-changes) has the numbers.

#### Swappiness, and the knob people get backwards

`vm.swappiness` is a number from 0 to 100 that sets how strongly the kernel prefers evicting anonymous pages over dropping page cache when it needs to reclaim memory. The default on nearly every distribution is 60. Since kernel 5.8 the maximum is 200, which lets you express a preference for swapping over cache reclaim rather than merely a balance.

The common misunderstanding is that it is a threshold: "swap when memory is 60% full". It is not. It is a relative preference applied only when the kernel is already reclaiming. A machine with plenty of free memory will not swap at any swappiness value.

```bash
$ cat /proc/sys/vm/swappiness
60
$ sysctl -w vm.swappiness=10          # until reboot
```

/etc/sysctl.d/99-memory.conf:

```ini
vm.swappiness = 10
vm.vfs_cache_pressure = 50
```

Apply it with `sysctl --system`. `10` is the sensible value for a server running one or two latency-sensitive things: swap exists, cold pages still get evicted, but the kernel reaches for the page cache first. Setting it to `0` does not disable swap. Since kernel 3.5 it means "do not swap unless we would otherwise have to OOM", which is a legitimate setting for a database host and a bad one for a general-purpose box, because you lose the cold-page eviction that was the point.

`vm.vfs_cache_pressure` controls how eagerly the kernel reclaims the caches for directory entries and inodes. The default of 100 is neutral; lowering it to 50 keeps filesystem metadata in memory longer, which helps a server with a lot of small files. It is a second-order setting and you should not touch it without a reason.

The one worth knowing about and almost never worth changing is `vm.overcommit_memory`. The default of `0` lets the kernel accept allocations larger than the memory it has, on the reasonable assumption that most programs ask for more than they use. Setting it to `2` makes allocation strictly accounted against RAM plus swap times `vm.overcommit_ratio`, which makes `malloc` fail honestly instead of the OOM killer firing later. It also breaks a lot of software that assumes it can over-allocate, including the JVM and Redis, so leave it alone unless you have a specific reason.

#### The OOM killer and how it chooses

When the kernel cannot satisfy an allocation and cannot reclaim anything, it invokes the out-of-memory killer. This is not a crash. It is a deliberate decision to terminate one process with `SIGKILL` so that the machine survives.

The victim is chosen by score. Every process has one, visible in `/proc/<pid>/oom_score`, derived mainly from how much memory it is using as a fraction of the total. The biggest consumer is usually chosen, which is why the OOM killer so reliably kills the exact thing you cared about: on a server, the largest process is your database or your game server, not the leaking cron job that pushed the machine over the edge.

You can bias that decision with `/proc/<pid>/oom_score_adj`, which runs from `-1000` to `1000` and is added to the score. `-1000` makes a process effectively exempt. `1000` volunteers it.

There are two different events that both get called OOM, and telling them apart is the single most useful diagnostic skill here:

- **Global OOM.** The whole machine ran out. Everything is at risk, the kernel picks a victim anywhere on the system, and the log line says `constraint=CONSTRAINT_NONE` and `global_oom`.
- **cgroup OOM.** One control group hit its own limit while the machine still has memory free. Only processes inside that group are candidates. The log line names a `task_memcg` path. This is what happens to a container, and to any systemd unit with `MemoryMax=` set.

That distinction decides the fix. A global OOM means buy more memory or use less. A cgroup OOM means one service exceeded a limit that you or your host set, and the rest of the machine was fine.

#### Reading the kill in the log

The kernel writes a full report, and it is worth learning to read rather than skim.

```bash
$ dmesg -T | grep -iE "out of memory|killed process|oom-kill"
$ journalctl -k -b | grep -i oom
$ journalctl -k --since "1 hour ago" | grep -A20 "Out of memory"
```

The two lines that matter look roughly like this:

```text
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,
  global_oom,task_memcg=/system.slice/minecraft.service,task=java,pid=2841,uid=1000
Out of memory: Killed process 2841 (java) total-vm:9412304kB, anon-rss:6182940kB,
  file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:13248kB oom_score_adj:0
```

Read `anon-rss`, not `total-vm`. `total-vm` is virtual address space, which for a JVM or a Go program is routinely several times the real usage and means almost nothing. `anon-rss` is the anonymous memory the process actually held in RAM, and that is the number to compare against your limit. In the example, a Java process holding about 5.9 GB was killed on a machine that could not find more.

Above those lines, the kernel prints a table of every process with its RSS, which tells you what else was resident at the time. That table is how you discover that the thing that died was not the thing that grew: a backup script that read a large file, or a second service that doubled in size, can push the largest process over the edge without ever being the victim.

If you find no OOM message at all and your service still disappeared, it was not the kernel. Check `systemctl status` for the unit's exit code, and look at [why your game server keeps restarting](https://www.renode.space/blog/why-your-game-server-keeps-restarting) for the other causes, which are more common than people expect.

#### Protecting the process you care about

The kernel's choice can be overridden, and systemd is the clean way to do it.

/etc/systemd/system/minecraft.service:

```ini
[Service]
OOMScoreAdjust=-500
MemoryHigh=5G
MemoryMax=6G
MemorySwapMax=512M
OOMPolicy=stop
```

`OOMScoreAdjust` biases the global kill decision so that the kernel prefers something else. `MemoryHigh` is a soft limit: exceed it and the cgroup is put under heavy reclaim pressure and throttled, but not killed. `MemoryMax` is the hard limit: exceed it and the cgroup gets its own OOM kill, leaving the rest of the machine untouched. `MemorySwapMax` caps how much of that limit can be swap.

The pattern worth copying is `MemoryHigh` a little below `MemoryMax`, on the services you do not trust. The soft limit gives the process a chance to reclaim before anything dies, and the hard limit means a runaway service takes itself down instead of taking the machine down. Run `daemon-reload` after editing, and see [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) for the rest of the unit file.

Recent Ubuntu also ships `systemd-oomd`, a userspace daemon that watches pressure stall information and kills a cgroup before the kernel has to. It acts earlier and more predictably than the kernel's killer, and it logs its reasons in `journalctl -u systemd-oomd`. If a service is dying with no kernel OOM message on Ubuntu 22.04 or later, check there before concluding it crashed on its own.

You can watch the pressure it reacts to directly:

```bash
$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.13 avg300=0.09 total=4192031
full avg10=0.00 avg60=0.04 avg300=0.02 total=1104822
```

`some` is the share of time at least one task was stalled waiting for memory; `full` is the share where everything was. A `full avg60` that is consistently above a few percent means the machine is spending real time on memory reclaim, and it is a better early warning than the `free` column ever was.

#### Swap, latency and the JVM

Swap and garbage collection are a bad combination, and this is where most game server operators meet the problem.

A JVM's heap is anonymous memory that the collector walks periodically. If the kernel has swapped out part of that heap because it looked cold, the next major collection has to read all of it back in, page by page, through the storage device. A collection that normally takes 40 milliseconds takes several seconds. On a Minecraft server that is a visible freeze for everyone online, and it repeats. The same logic applies to any runtime with a tracing collector and to any database that keeps a large in-process cache.

Practical rules:

- Size the heap to fit in RAM with room left over. `-Xmx` plus the JVM's off-heap overhead plus the operating system should be comfortably under the total. [Minecraft JVM flags and Java versions](https://www.renode.space/blog/minecraft-jvm-flags-and-java-versions) covers what that overhead actually is, and [how much RAM a Minecraft server needs](https://www.renode.space/blog/how-much-ram-does-a-minecraft-server-need) covers the sizing.
- Keep swappiness low on these machines. `vm.swappiness = 10` or `1`.
- Cap the unit's swap with `MemorySwapMax=` rather than trusting swappiness alone.
- Do not add swap to fix an undersized server. If the heap does not fit, the answer is a bigger plan, not a bigger swap file. [When to upgrade your plan](https://www.renode.space/blog/when-to-upgrade-your-plan) covers recognising the difference.

To see whether a specific process is currently swapped out:

```bash
$ grep VmSwap /proc/2841/status
VmSwap:      412360 kB
$ for p in /proc/[0-9]*; do
    printf "%s %s\n" "$(grep VmSwap $p/status 2>/dev/null | awk '{print $2}')" \
      "$(cat $p/comm 2>/dev/null)"
  done | sort -rn | head
```

If the process you care about has hundreds of megabytes in `VmSwap`, that is your latency problem, and `swapoff -a && swapon -a` will pull it all back into RAM immediately if you have the free memory to do it.

#### Thrashing: when swap makes everything worse

Thrashing is what happens when the working set does not fit and the kernel spends its time moving the same pages in and out. The symptom is distinctive once you have seen it: the load average is high, the CPU is mostly idle, everything is slow, and nothing has crashed.

```bash
$ vmstat 1 10
$ iostat -x 2          # from the sysstat package
```

In `vmstat`, the `si` and `so` columns are pages swapped in and out per second. Occasional small numbers are fine and normal. Sustained hundreds or thousands with a high `wa` (iowait) in the CPU columns is thrashing, and no amount of tuning fixes it. The machine needs less work or more memory.

This is also why a small swap file is a feature rather than a compromise. With 2 GB of swap, a runaway process hits the OOM killer in a few minutes and the machine recovers. With 16 GB of swap it grinds for an hour first, your monitoring alerts on latency rather than on a crash, and you spend that hour trying to SSH into a box too busy to give you a shell. [Reading a server load graph](https://www.renode.space/blog/reading-a-server-load-graph) has the shapes to recognise.

On a managed RE:NODE plan this whole failure mode is deliberately removed. Each server runs in its own container with a hard memory limit, and at that limit the kernel stops the container and it restarts clean rather than being left to swap. That is the opposite of upstream Pterodactyl's default, and it is a trade: you lose the chance that a brief spike gets absorbed, and you gain a server that comes back in seconds instead of one that is technically up and unusable for an hour. CPU works the same way - a hard throttle to the share bought, so a server at 100% is slow rather than broken. On a VDS, all of that is yours to configure, which is exactly what this post has been about. [Choosing between a VDS and a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) weighs the two properly.

Worth mentioning as an alternative on small boxes: `zram` creates a compressed block device in RAM and uses it as swap. Pages are compressed instead of written to disk, typically to a third or half their size, so you get some of the benefit of swap with none of the storage latency. On Debian and Ubuntu the `zram-tools` package configures it in a few lines. It is genuinely useful on a 1-2 GB machine and mostly pointless above 8 GB, where the real answer to memory pressure is to use less of it.

#### FAQ

##### Should a server have swap at all?

Yes, a small amount. Zero swap means cold anonymous pages sit in RAM forever and the OOM killer arrives with no warning at all. One to two gigabytes gives the kernel somewhere to put pages nobody is touching, without providing enough room to thrash for an hour.

##### Does more swap prevent the OOM killer?

Only by postponing it. The killer fires when the kernel cannot reclaim anything, and a huge swap file means it takes much longer to reach that point, spent at a fraction of the machine's speed. If you are hitting OOM regularly, the fix is less memory usage or more RAM.

##### What does swappiness 0 do?

It tells the kernel to avoid swapping anonymous memory until the alternative is an OOM kill. It does not disable swap, and it does not disable page cache reclaim. Use `10` for a general server; `0` or `1` only on a host dedicated to a database or a JVM.

##### Why did the OOM killer kill my database instead of the thing that leaked?

Because it scores by memory usage, and your database was the largest process on the machine. The leak pushed the system over the edge; the score picked the victim. Use `OOMScoreAdjust` on the service you want protected and `MemoryMax` on the one you do not trust.

##### How do I tell whether a container or the whole machine ran out?

Read the `oom-kill:` line in `dmesg`. `constraint=CONSTRAINT_NONE` with `global_oom` means the machine. A `task_memcg` path pointing at a specific slice or container, without `global_oom`, means that cgroup hit its own limit while the host was fine.

##### Is swap on NVMe fast enough to not matter?

It is much better than it was on spinning disks, and still roughly a thousand times slower than RAM. NVMe changes swap from catastrophic to merely bad. It does not make swap a substitute for memory, and it does not save a garbage collector that has to fault its whole heap back in.

A RE:NODE VDS is full root access on Intel i9-9900K hardware in Germany with NVMe storage, prepared by hand and delivered within 24 hours, so swap, sysctl and the OOM policy are all yours to set. On managed plans the decision is already made: at the memory limit the container is stopped and restarted clean rather than left to swap.

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

---

### Several game servers on one VDS

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/multiple-game-servers-on-one-vds

Running several game servers on one VDS is a real and sensible thing to do, and it is almost never worth it for one or two. Below about three servers, a managed plan per game costs less in money once you value your own hours above zero, and costs nothing at all in evenings. The case for one box appears at four, five or six servers, when memory starts to pool usefully, when you want them all on one address, and when you are already comfortable with Linux.

If you have decided, the job has five parts: a port map you write down before you install anything, one unprivileged user per server, a systemd unit per server with resource limits on it, a way to send commands without a panel, and staggered updates and backups so that five things do not all hammer the same disk at four in the morning. This post goes through each, plus LinuxGSM and the option of installing a panel on your own machine.

#### Be honest about whether you should

Start here, because the rest of the post is a lot of work to do for the wrong reason.

A managed game plan hands you a server about a minute after payment clears, with the install done, the ports allocated, a console, a file manager, SFTP, scheduled tasks and backup slots. A VDS hands you an empty Debian box and a root password within a day. Everything else in this post is the gap between those two sentences, and it is roughly a weekend the first time.

| | Separate managed plans | One VDS |
| --- | --- | --- |
| Time to first server running | About a minute | 1-3 hours |
| Memory | Fixed per plan, paid whether used or not | Pooled across everything |
| Ports | Allocated per plan, more on the Network tab | Any port you like |
| Console for each server | Built in, per server | RCON, tmux, or you build it |
| Giving a friend restart access | Subuser with limited permissions | An SSH account, or a script |
| Updates and patching | Not your problem | Yours, on a schedule |
| A crash at 3am | Watched for and restarted | Whatever `Restart=` does |

The honest dividing line is memory shape rather than count. Five servers that are each 2 GB and idle most of the week pool beautifully on a 16 GB VDS, because the one that spikes borrows from the four that are quiet. Two servers that are each genuinely 6 GB and busy do not pool at all, and you have bought a sysadmin job for nothing. [Choosing between a VDS and a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) has the full arithmetic, including what the operating system itself costs you before you run anything.

One more thing that is not obvious until you have done it: the second game server is easy and the fifth is not. Each one adds a port range to remember, an update schedule, a backup, a different config format and a different way of taking commands. The work is not linear.

#### Sizing: what actually fits

Two numbers constrain you, and it is usually not the one people check.

Memory is the easy one. Subtract 300-400 MB for a minimal Debian or Ubuntu with nothing installed, more if you add Docker, a web server or monitoring, then divide what is left. An 8 GB VDS realistically gives you about 7 GB of game server.

CPU is the constraint that bites. Almost every game server simulates its world on one thread. Four servers all ticking at once need four threads that are each fast enough, and no amount of total core count helps if they are slow cores. On a two-vCPU box, two busy game servers are fine and four busy ones interfere with each other during peak hours, even with memory to spare. This is the same effect described in [shared CPU and noisy neighbours](https://www.renode.space/blog/shared-cpu-and-noisy-neighbours), except that this time the noisy neighbour is you.

| VDS size | Comfortable load | Not comfortable |
| --- | --- | --- |
| 8 GB, 2 vCPU | Two or three small servers, staggered peaks | Four busy servers at once |
| 16 GB, 4 vCPU | Four or five, or one big modded world plus two small | Eight of anything |
| 24 GB, 6 vCPU | Six, or a mixed stack with a database and a web app | A dozen, whatever the memory says |

Plan for peak, not for average. Five servers that each idle at 800 MB and each peak at 3 GB will not survive a Friday night together on 16 GB, and the resulting out-of-memory kill takes down whichever server was largest at the time rather than whichever one caused it. [Swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer) explains how that decision gets made and how to bias it.

#### A port map, written down first

Do this before you install anything. Five minutes now saves an afternoon of a server that starts, binds nothing, and logs an "address already in use" line you will not notice for an hour.

| Game | Default ports | Notes |
| --- | --- | --- |
| Minecraft Java | `25565/tcp` | Plus `25575/tcp` if you enable RCON |
| Valheim | `2456/udp`, `2457/udp` | Query is always the game port plus one |
| Palworld | `8211/udp` | Set with `-port` |
| Counter-Strike 2 | `27015/udp`, `27015/tcp` | Plus `27020/udp` for GOTV |
| Terraria | `7777/tcp` | One port, no query |
| Factorio | `34197/udp` | One UDP port |

```diagram
title: One address, several servers, one port map
players = Players | different games
ip = Public IP | 203.0.113.10
mc = Minecraft | TCP 25565
val = Valheim | UDP 2456-2457
cs2 = CS2 | UDP 27015
disk = NVMe disk | shared by everything
players -> ip
ip -> mc : TCP 25565
ip -> val : UDP 2456
ip -> cs2 : UDP 27015
mc --> disk
val --> disk
cs2 --> disk
```

Three rules make the map hold together. Give each server a block rather than a single number, because most games quietly want a second port for queries or a third for a feature you enable later. Leave gaps - space them ten or twenty apart, not one. And write the whole thing in a file on the machine, because in eight months you will be trying to remember which server owns `27025`.

Source-engine games are the awkward case. Two instances on one machine need distinct values for `-port`, `+tv_port` and `+clientport`, and setting only the first gives you two servers fighting over the same GOTV socket, which fails in a way the log describes badly. [Game server ports explained](https://www.renode.space/blog/game-server-ports-explained) covers why query ports exist and what happens when only one of a pair is reachable.

Then open exactly those ports and nothing else:

```bash
$ ufw default deny incoming
$ ufw allow 22/tcp
$ ufw allow 25565/tcp          # Minecraft
$ ufw allow 2456:2457/udp      # Valheim game and query
$ ufw allow 8211/udp           # Palworld
$ ufw enable
$ ufw status numbered
```

RCON ports are the exception: do not open them to the internet. Bind RCON to `127.0.0.1` and reach it over an SSH tunnel, which costs nothing and removes an entire category of problem. [RCON safely](https://www.renode.space/blog/rcon-safely) explains why an exposed RCON port with a guessable password is the fastest way to lose a server, and [the ufw firewall guide](https://www.renode.space/blog/ufw-firewall-guide) covers the rest of the ruleset.

#### One user per server

This is the part people skip and later regret. Every game server gets its own unprivileged user and its own directory tree. Nothing runs as root, and nothing runs as the same user as anything else.

```bash
$ adduser --disabled-password --gecos "" --home /srv/valheim valheim
$ adduser --disabled-password --gecos "" --home /srv/minecraft minecraft
$ chown -R valheim:valheim /srv/valheim
$ sudo -u valheim -i           # become that user to install or edit
```

The reason is containment. Game servers run mods and plugins written by strangers, occasionally load content uploaded by players, and expose network services that parse untrusted input. When one of them is compromised - and [keeping a modded server clean](https://www.renode.space/blog/keeping-a-modded-server-clean) is the fuller version of that worry - the blast radius should be one directory, not the machine. A separate user also means a stray `rm -rf` in the wrong terminal fails with a permission error instead of succeeding.

There is a practical benefit too. `ps aux` and `systemd-cgtop` become readable, because the user column tells you immediately which server is using the CPU. Separate Steam installs go in separate homes, so `steamcmd +force_install_dir /srv/valheim/server` and `+force_install_dir /srv/pz/server` never collide. [SteamCMD explained](https://www.renode.space/blog/steamcmd-explained) has the update script pattern.

#### systemd is the supervisor

Not `tmux`, not `screen`, not `nohup`. A process started in a terminal multiplexer does not start at boot, does not restart after a crash, and is invisible to anything you set up later. Use a systemd unit, and use a template so that adding the fifth server is copying one environment file rather than one unit file.

/etc/systemd/system/gameserver@.service:

```ini
[Unit]
Description=Game server: %i
After=network-online.target
Wants=network-online.target

[Service]
User=%i
Group=%i
WorkingDirectory=/srv/%i
EnvironmentFile=/etc/gameservers/%i.env
ExecStart=/srv/%i/start.sh
Restart=on-failure
RestartSec=20
KillSignal=SIGINT
TimeoutStopSec=120
CPUQuota=150%
MemoryHigh=3500M
MemoryMax=4G
Nice=-5

[Install]
WantedBy=multi-user.target
```

`%i` is the instance name, so `systemctl enable --now gameserver@valheim` runs `/srv/valheim/start.sh` as the `valheim` user with `/etc/gameservers/valheim.env` loaded. One unit file, one line per server.

Four directives are doing real work here:

- `KillSignal=SIGINT` is the one people leave out and the one that costs worlds. Many game servers save on a clean interrupt and lose everything since the last autosave on a `SIGKILL`. The default unit sends `SIGTERM` and gives up after 90 seconds; `TimeoutStopSec=120` gives a large world time to finish writing.
- `CPUQuota=150%` caps that server at one and a half cores. This is the setting that stops one modded world from starving the other four during a raid, and it is the main reason to prefer systemd over LinuxGSM's own supervision.
- `MemoryMax` gives the server its own ceiling. Hitting it kills only that cgroup, not the largest process on the machine, which is a much better failure than the global out-of-memory kill.
- `Restart=on-failure` with a `RestartSec` of fifteen or twenty seconds. Do not use `always` with a one-second delay: a server that fails to start loops forever and fills your disk with logs.

Then check what everything is actually using:

```bash
$ systemctl status gameserver@valheim
$ journalctl -u gameserver@valheim -f
$ systemd-cgtop
$ systemctl list-units 'gameserver@*'
```

`systemd-cgtop` is the per-service version of `top`, and on a box with five servers it is the fastest way to see which one has gone wrong. The rest of the unit file syntax is in [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps).

#### Sending commands without a panel

This is the friction nobody warns you about. On a panel there is a console per server. On a VDS, a systemd-managed process has no terminal you can type into, and `journalctl` is read-only.

Two workable answers. Where the game supports RCON, use it, bound to localhost:

```bash
$ mcrcon -H 127.0.0.1 -P 25575 -p "$RCON_PASS" "say Restart in 5 minutes" "save-all"
$ ssh -L 25575:127.0.0.1:25575 deploy@203.0.113.10   # tunnel from your laptop
```

Where it does not, run the server inside a `tmux` session owned by its user and drive it with `send-keys`:

```bash
$ sudo -u valheim tmux send-keys -t valheim "save" Enter
$ sudo -u valheim tmux attach -t valheim      # detach again with Ctrl-b d
```

That is the approach LinuxGSM takes, and it works, at the cost of a slightly odd systemd unit. Whichever you pick, script the things you do often - announce, save, stop - because doing them by hand across five servers at update time is how steps get missed.

#### LinuxGSM: what it gives you and where it stops

LinuxGSM is a set of shell scripts that installs, configures, updates, backs up and monitors dedicated servers for well over a hundred games. It is genuinely good, it is free, and for a mixed stack it saves hours of reading each game's installation notes.

```bash
$ wget -O linuxgsm.sh https://linuxgsm.sh && chmod +x linuxgsm.sh
$ bash linuxgsm.sh vhserver          # creates the vhserver script
$ ./vhserver install
$ ./vhserver start
$ ./vhserver details                 # ports, paths, status
$ ./vhserver update
$ ./vhserver backup
$ ./vhserver console
```

Each game has a short code - `vhserver` for Valheim, `mcserver` for Minecraft, `gmodserver` for Garry's Mod, `tf2server` for Team Fortress 2, and so on. Settings live in `lgsm/config-lgsm/<code>/<code>.cfg`, which overrides the shipped defaults the same way `jail.local` does for fail2ban.

LinuxGSM refuses to run as root, which enforces the one-user-per-server rule for you. Its `details` command prints the exact ports a game needs, which is the fastest way to build the port map described above.

Two honest limits. First, its `monitor` command is designed to be run from cron every few minutes, and it will happily restart a server that systemd is also trying to restart. Pick one supervisor: either systemd units with `Restart=on-failure` and no `monitor` cron entry, or LinuxGSM's own monitoring and no systemd unit. Running both produces a server that restarts twice and a log that makes no sense.

crontab -u vhserver -e:

```text
*/5 * * * * /srv/valheim/vhserver monitor > /dev/null 2>&1
0 4 * * * /srv/valheim/vhserver update > /dev/null 2>&1
```

Second, LinuxGSM does not give you resource limits. It starts the process; it does not cap its CPU or memory. On a box with one or two servers that is fine. On a box with five, the `CPUQuota` and `MemoryMax` lines above are the difference between one bad server and five bad servers, so most people end up with systemd units regardless and use LinuxGSM for installation and updates only. [Cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) covers the schedule syntax if the five fields are unfamiliar.

#### Updates, backups and restarts, staggered

Five servers doing the same thing at the same time is the mistake that turns a working box into an unusable one for ten minutes every night.

- **Stagger the backups.** A tar of a 40 GB world is a sustained read and a sustained write on the same device. Five at once saturates it. Put them twenty minutes apart.
- **Stagger the restarts.** Restarting everything at 05:00 means every server reloads its world simultaneously, which is the heaviest thing any of them do. [Restart schedules that help](https://www.renode.space/blog/restart-schedules-that-help) covers which servers benefit from a restart at all, because several do not.
- **Do not auto-update a modded server.** A game patch on the day it lands breaks every mod until authors catch up. Update vanilla servers automatically; update modded ones by hand, after checking, and keep a copy of the working mod folder. [What to do when a mod update breaks](https://www.renode.space/blog/what-to-do-when-a-mod-update-breaks) is the recovery procedure.

A systemd timer is tidier than cron for this, because the log ends up in the journal beside the service it backs up:

```bash
$ tar -czf /srv/backups/valheim-$(date +%F-%H%M).tar.gz -C /srv/valheim worlds_local
$ find /srv/backups -name 'valheim-*.tar.gz' -mtime +14 -delete
$ restic -r sftp:backup@198.51.100.5:/srv/restic backup /srv/valheim/worlds_local
```

The critical part is the destination. A backup on the same disk as the server protects against a corrupt save and nothing else - not a deleted directory, not a failed disk, not a mistake made at midnight. Send it somewhere else, and restore one occasionally to prove it works, because [a backup nobody has restored is a hypothesis](https://www.renode.space/blog/backups-that-actually-restore). [Testing a restore before you need it](https://www.renode.space/blog/testing-a-restore-before-you-need-it) is the ten-minute version of that habit.

Finally, the machine itself. Weekly `apt update && apt full-upgrade`, automatic security updates enabled, a default-deny firewall, key-only SSH and [fail2ban](https://www.renode.space/blog/fail2ban-guide) on the SSH port. None of that happens by itself, and a box running five game servers is a more interesting target than a box running one. [The first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds) is the checklist, and [Linux commands for server admins](https://www.renode.space/blog/linux-commands-for-server-admins) covers the daily reading.

#### Or install a panel on your own VDS

The common ambition is both: root access and a real interface so that other people can restart things without an SSH key. Pterodactyl is the usual choice and it is perfectly installable, but be clear about what you are taking on.

It is two components. The panel is a Laravel application needing a web server, PHP with its extensions, a MySQL-compatible database, Redis and Composer. Wings is a Go daemon needing Docker, talking to the panel over its own port, with SFTP served separately. Upstream defaults put the Wings API on `8080` and its SFTP listener on `2022`, with the panel on `80` and `443`.

That is five or six services to keep patched, a certificate for the panel hostname, and a Docker image per game. Budget around 1 GB of memory before any game server runs. On a 24 GB VDS that is a rounding error; on the 8 GB tier it is an eighth of your machine spent on infrastructure rather than on players. [What a Pterodactyl panel gives you](https://www.renode.space/blog/pterodactyl-panel-explained) covers the architecture in more detail.

A reasonable rule: install a panel on your own machine when hosting for other people is the product, and you are charging for it. Do not install one so that three friends can press Restart. On a managed RE:NODE plan that is a subuser account with granular permissions - console only, files only, no billing - on a role that can be time-boxed, with a per-server activity log. [Subusers and least privilege](https://www.renode.space/blog/subusers-and-least-privilege) walks through setting that up, and it takes about two minutes.

That is the honest summary of this whole post. Every managed plan gets the same panel with the same console, file manager, per-server SFTP, schedules and backup slots, and the server exists about a minute after payment clears. A VDS gets you full root access, prepared by hand and delivered within 24 hours, and everything above becomes yours. Pick the one that matches how many servers you are actually running and how much of your week you want back.

#### FAQ

##### How many game servers fit on one VDS?

Count fast threads, not gigabytes. Most game servers simulate on one thread, so a two-vCPU box comfortably runs two or three that peak at different times, and a six-vCPU box runs five or six. Memory sets a hard ceiling; CPU sets the one you actually feel.

##### Can they all share one IP address?

Yes. That is the normal arrangement, and it is why the port map matters. Each server binds a different port on the same address, and players connect with `address:port`. Only one server per protocol can own a given port number.

##### Should I use Docker for each server?

You can, and it gives you clean isolation and easy limits, but it adds an image to maintain per game and a firewall caveat: Docker publishes ports by writing its own rules, which are consulted before ufw's, so a container can be reachable even when the firewall says the port is closed. Bind to `127.0.0.1` where you can. [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) has the detail.

##### Is tmux good enough to keep a server running?

For something you are watching, yes. For anything permanent, no. A `tmux` session does not start after a reboot and does not restart after a crash. Use a systemd unit and keep `tmux` for the console, if the game has no RCON.

##### What happens when one server uses all the memory?

Without limits, the kernel picks the largest process on the machine and kills it, which is often not the one that misbehaved. With `MemoryMax` on each unit, the offender hits its own ceiling and only it dies. Set the limits.

##### Is this cheaper than separate managed plans?

Sometimes. Below three servers, almost never, once you count the hours. Above five, usually, because memory pools and you pay for the machine rather than for five separate ceilings. Price both, then add a realistic number of hours per month to the VDS side before comparing.

A RE:NODE VDS is full root access on Intel i9-9900K hardware in Germany with NVMe storage, prepared by hand and delivered within 24 hours - the port map, the users and the systemd units are yours to write. If two or three servers is the real answer, [managed game plans](https://www.renode.space/game-servers) come with the panel, the ports and the backup slots already done.

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

---

### Nginx reverse proxy: server blocks and TLS

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/nginx-reverse-proxy-guide

Your application listens on `127.0.0.1:3000`. Visitors type a name and expect port 443 with a padlock. Nginx is the twelve lines of configuration that connect those two facts, and once you have written them once you will write them for every service you ever deploy. This guide is the working version of those lines: a server block that proxies correctly, the four headers your application needs, a certificate that renews itself, and the specific configuration that websockets and file uploads require and do not get by default.

Everything here assumes one machine with root - a VDS or a dedicated box - running nginx as a system package with your application beside it as a `systemd` service or a container. If you want the conceptual version of what a reverse proxy is doing and why, [what a reverse proxy does](https://www.renode.space/blog/what-a-reverse-proxy-does) is that post. This one is the configuration.

#### Where nginx keeps its configuration

On Debian and Ubuntu, `/etc/nginx/nginx.conf` holds global settings and ends with two include lines. One pulls in `/etc/nginx/conf.d/*.conf`, the other pulls in `/etc/nginx/sites-enabled/*`. The convention is to write a file per site in `sites-available` and symlink the ones you want live into `sites-enabled`, so disabling a site is removing a link rather than deleting a file.

```bash
$ sudo apt install -y nginx
$ sudo nano /etc/nginx/sites-available/app.conf
$ sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
$ sudo rm /etc/nginx/sites-enabled/default
$ sudo nginx -t && sudo systemctl reload nginx
```

On RHEL, Rocky and Alma there is no `sites-available` split; everything goes in `/etc/nginx/conf.d/` with a `.conf` extension. The server blocks themselves are identical.

Three commands do all the work. `nginx -t` parses the whole configuration and reports the file and line number of any error, and you should never reload without it. `systemctl reload nginx` re-reads the configuration without dropping connections - old worker processes finish their current requests and exit, new ones start with the new configuration. `nginx -T` prints the entire merged configuration including every include, which is how you find the stray file that is overriding you.

The default site is worth deleting or replacing. It answers on port 80 for any hostname that does not match another block, which means a request for a domain you have never heard of gets your default page. A catch-all that closes the connection without answering is tidier:

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

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

`444` is an nginx-specific code meaning "close without a response". Only one block per port may carry `default_server`, and a second one produces `duplicate default server` at config test time.

#### The smallest server block that works

/etc/nginx/sites-available/app.conf:

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
    }
}
```

That is a complete, working reverse proxy. Certbot will add the TLS half in a moment. A few things in it are not obvious:

`proxy_http_version 1.1` should be there from the start. Nginx talks HTTP/1.0 upstream by default, which breaks keepalive to the backend and makes websocket upgrades impossible. There is no downside to setting it.

`server_name` is how nginx picks a block, matching against the `Host` header. Several names on one block are space-separated: `server_name example.com www.example.com;`. A request whose `Host` matches nothing lands in the default server, which is why the catch-all above matters.

`$proxy_add_x_forwarded_for` appends the client address to any existing `X-Forwarded-For` header rather than replacing it, which is correct when there is another proxy in front of you and is a hole when there is not - a client can send its own header and you will pass it along. If nothing is in front of nginx, use `proxy_set_header X-Forwarded-For $remote_addr;` and remove the ambiguity.

#### The trailing slash in proxy_pass

This is the single most common nginx mistake, and it is silent until a route 404s.

```nginx
# Passes /api/users through unchanged: backend sees /api/users
location /api/ {
    proxy_pass http://127.0.0.1:8000;
}

# Strips the location prefix: backend sees /users
location /api/ {
    proxy_pass http://127.0.0.1:8000/;
}
```

The rule: if `proxy_pass` contains a URI part - anything after the host and port, including a bare `/` - nginx replaces the matched location prefix with it. If it contains no URI part, the request URI is passed through untouched. One character decides whether your API sees `/api/users` or `/users`.

Whichever you choose, the application has to agree. An application that builds links assuming it lives at the root will emit broken URLs when served under `/api/`, which is why hosting applications on subdomains is usually less work than hosting them on paths. And note that this rewriting only applies to prefix locations - combining a URI in `proxy_pass` with a regex location is a configuration error nginx rejects at test time.

#### TLS with certbot, and what renewal actually needs

```bash
$ sudo apt install -y certbot python3-certbot-nginx
$ sudo certbot --nginx -d app.example.com
$ sudo certbot renew --dry-run
```

The nginx plugin reads your server block, obtains a certificate, edits the block to add a TLS listener and a redirect from port 80, and installs a renewal job. The result looks like this:

```nginx
server {
    server_name app.example.com;
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        # ... the header lines from above
    }
}

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}
```

`http2 on;` as a separate directive is the syntax from nginx 1.25.1 onwards. On older builds it is a parameter on the listen line - `listen 443 ssl http2;` - and mixing the two styles produces a deprecation warning rather than a failure.

Three conditions have to hold before a certificate can be issued or renewed, and every certbot failure is one of them:

1. **The DNS name resolves to this machine.** Issuance proves you control the name by answering a challenge at it. Point the A record first, wait for it to propagate, then run certbot. [DNS records explained](https://www.renode.space/blog/dns-records-explained) covers the record types and the TTL wait.
2. **Port 80 is open and reaches nginx.** The HTTP-01 challenge fetches a file under `/.well-known/acme-challenge/`, over plain HTTP, even for a site that redirects everything to HTTPS. Blocking port 80 in [UFW](https://www.renode.space/blog/ufw-firewall-guide) is the most common self-inflicted renewal failure. Certbot's redirect block handles the challenge path correctly; if you write the redirect by hand, exempt that location.
3. **Nginx reloads after renewal.** The certbot packages install a deploy hook that does this. If you renew by some other route, add `--deploy-hook "systemctl reload nginx"`, because nginx holds the old certificate in memory until it is told otherwise.

Renewal runs twice a day from a `systemd` timer and only acts when a certificate is within thirty days of expiry, so a transient failure has weeks of margin. Check it is armed with `systemctl list-timers | grep certbot`. The mechanics of what the challenge proves are in [HTTPS and Let's Encrypt explained](https://www.renode.space/blog/https-and-lets-encrypt-explained).

#### WebSockets, streaming and timeouts

A websocket starts as an ordinary HTTP request carrying `Connection: Upgrade` and `Upgrade: websocket`. Nginx does not forward hop-by-hop headers by default, so the upgrade dies in transit unless you say otherwise. The standard pattern uses a `map` at the `http` level so that non-websocket requests are not affected:

/etc/nginx/conf.d/upgrade.conf - http context:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
```

in the location block:

```nginx
location /socket.io/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}
```

`proxy_read_timeout` defaults to 60 seconds, and it is measured between reads, not for the whole connection. An idle websocket is therefore cut every minute, producing a reconnect loop that looks exactly like a network fault. Raise the timeout on websocket routes and send an application-level ping every twenty to thirty seconds so the connection is never idle long enough to qualify.

Server-sent events and any streaming response need one more line. Nginx buffers upstream responses by default, so a stream is held until enough of it has arrived and the client sees a long silence followed by everything at once:

```nginx
location /events {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 24h;
}
```

The application can also switch buffering off per-response by sending `X-Accel-Buffering: no`, which is tidier when only some endpoints stream. [WebSockets behind a reverse proxy](https://www.renode.space/blog/websockets-behind-a-reverse-proxy) covers the sticky-session problem that appears once there is more than one backend instance.

#### Several apps behind one nginx

The point of a proxy is that one address and one port serve any number of services. Two ways to split them, and subdomains are almost always the better one:

```nginx
# By hostname: two server blocks, two certificates, no path rewriting
server { server_name app.example.com; location / { proxy_pass http://127.0.0.1:3000; } }
server { server_name api.example.com; location / { proxy_pass http://127.0.0.1:8000; } }
```

```nginx
# By path: one certificate, but the backend must know it lives under /api/
location /api/ { proxy_pass http://127.0.0.1:8000/; }
location /     { proxy_pass http://127.0.0.1:3000; }
```

Longest-prefix wins among prefix locations, so `/api/` beats `/` for a request to `/api/users` regardless of the order they appear in the file. Regex locations, written `location ~ ^/pattern`, are evaluated in file order and take precedence over prefix matches, which is a good reason to use as few of them as you can.

For a backend you will keep-alive to, declare it as an upstream. The two extra lines are required: keepalive to an upstream needs HTTP/1.1 and an empty `Connection` header, or nginx sends `Connection: close` on every request and the pool never gets used.

```nginx
upstream app {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
```

An upstream with several `server` lines gives you round-robin load balancing, with `ip_hash` or `least_conn` as alternatives and `backup` for a standby. If the application is in a container, the proxy can reach it by container name instead of a port - see [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) for that shape, where nothing but the proxy publishes a port at all.

A Unix socket is a valid upstream too, and it is marginally faster than loopback TCP as well as impossible to reach from the network: `proxy_pass http://unix:/run/app/app.sock;`. Make sure the nginx user can read the socket.

#### Static files, uploads and limits

Nginx serves files faster than any application runtime, so let it. Serving the static directory directly also stops those requests from waking your application at all:

```nginx
location /static/ {
    alias /srv/app/static/;
    access_log off;
    expires 30d;
    add_header Cache-Control "public, immutable";
}

client_max_body_size 25m;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
server_tokens off;
```

`alias` replaces the matched location prefix with the given path; `root` appends the whole URI to it. Both are correct in different situations and mixing them up produces a 404 with a very clear line in `error.log` showing the path nginx tried to open. Read that line rather than guessing.

`client_max_body_size` defaults to **1 MB**, which is smaller than most upload forms expect, and exceeding it produces a 413 before the request ever reaches your application. Set it to the largest upload you intend to accept and no larger. `expires` and `Cache-Control` on hashed asset filenames are free performance; on files whose names do not change, they are a way to serve stale content for a month, so be sure which you have - [HTTP caching headers explained](https://www.renode.space/blog/http-caching-headers-explained) draws that line properly.

One caution about `add_header`: it is not cumulative across levels. A header added in a `location` block replaces, rather than adds to, headers added in the parent `server` block. If you set security headers globally and then add a caching header in one location, the security headers vanish from that location. Repeat them, or use the `always` parameter carefully and test with `curl -I`.

Rate limiting is two directives and worth having on a login endpoint:

```nginx
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /login {
    limit_req zone=login burst=10 nodelay;
    proxy_pass http://127.0.0.1:3000;
}
```

Note that this limits by `$binary_remote_addr`, the address nginx sees. If something else proxies to nginx, that is one address for everybody. Either use the real IP module - `set_real_ip_from` with the upstream proxy's range plus `real_ip_header X-Forwarded-For` - or limit on a variable derived from the forwarded header. [Rate limits and abuse](https://www.renode.space/blog/rate-limits-and-abuse) covers choosing the numbers.

#### 502, 504, 413 and the rest

Every answer here starts in `/var/log/nginx/error.log`. Nginx writes a specific reason for nearly every failure, including the exact upstream it tried and the errno it got back.

**502 Bad Gateway.** Nginx could not get a valid response from the backend. The error log will say `connect() failed (111: Connection refused)` - the application is not running or is on a different port - or `no live upstreams`, or a `104: Connection reset by peer` from a backend that crashed mid-request. Check the process with `sudo ss -lntp`, then its own logs. If the application runs in a container and nginx on the host, the application must listen on an address the host can reach, not on the container's loopback.

**504 Gateway Timeout.** The backend accepted the connection and did not answer within `proxy_read_timeout`. This is a slow request, not a proxy fault. Find the slow endpoint before raising the timeout, because raising it usually just moves the failure to the browser.

**413 Request Entity Too Large.** `client_max_body_size`. See above.

**404 on routes that exist.** The `proxy_pass` trailing slash, or `alias` versus `root`. The error log prints the path that was attempted.

**A redirect loop.** The application forces HTTPS by checking the scheme of its own connection, which is plain HTTP because nginx terminated TLS. Send `X-Forwarded-Proto` and turn on the framework's trusted-proxy setting; do not remove the redirect.

**Every visitor has the same IP in the application log.** The application is not reading `X-Forwarded-For`. Each framework has a switch for that, off by default for good reasons.

**`nginx -t` passes but the change did nothing.** Another server block matched first, usually the default server, or you edited `sites-available` and never made the symlink. `nginx -T | grep server_name` shows every block nginx actually loaded.

**Permission denied connecting upstream on RHEL or Rocky.** SELinux. `sudo setsebool -P httpd_can_network_connect 1` is the one-line fix, and `sudo ausearch -m avc -ts recent` is how you confirm that was the cause.

Run nginx and the application as separate services so each can be restarted alone; [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) has the unit file, including the `Restart=` and `After=network.target` lines that decide what comes back after a reboot.

If none of this is work you want to do, it is worth knowing it is not compulsory: RE:NODE's app and web plans include a proxy slot that does the same job. You point an A record at the address the panel shows, the certificate is issued and renewed automatically inside a 21-day window, and the real client address arrives in `X-Forwarded-For`. The reason to run nginx yourself is everything a managed proxy deliberately does not do - custom locations, rate limit zones, several applications on paths, a Unix socket upstream - and that needs a machine with root.

#### FAQ

##### Do I need nginx if my app can serve HTTPS itself?

Usually yes, and not for speed. The proxy keeps the certificate out of your application, lets you run several services on one address and port, gives you one place for rate limits and upload limits, and lets you restart the application without dropping the listener. For a single internal service on a private network, you can skip it.

##### Nginx or Caddy?

Caddy gets certificates automatically with no plugin and has a much shorter configuration file, which makes it the easier choice for a simple site. Nginx has more knobs, far more documentation and examples, and is what most guides and most hosting stacks assume. Either is a fine answer; nginx is the one you will meet more often.

##### Why does my app see nginx's IP instead of the visitor's?

Because nginx made the connection to it. Send `X-Forwarded-For` and `X-Forwarded-Proto`, then enable the trusted-proxy setting in your framework so it reads them. Trusting those headers unconditionally is unsafe, which is why it is never on by default.

##### Can nginx proxy a game server?

Not as an HTTP proxy. Games use their own protocols, mostly over UDP, and `proxy_pass` speaks HTTP. Nginx does have a `stream` module for plain TCP and UDP forwarding, which can pass game traffic through, but it is a different block and it gives you none of the hostname routing that makes HTTP proxying useful.

##### How do I test the configuration without breaking the live site?

`sudo nginx -t` parses everything and refuses to reload on a syntax error, so a broken file cannot take the site down through `reload`. For behaviour rather than syntax, add the new server block on a spare hostname, test it with `curl -H 'Host: new.example.com' http://127.0.0.1/`, then move the real name across.

##### Should I reload or restart?

Reload. It keeps the listening sockets open and lets existing connections finish on the old workers, so nothing is dropped. Restart only when you change something nginx reads at startup, such as the user it runs as or the set of listening ports in some setups.

Running your own nginx, your own certificates and your own upstreams needs root on a real machine: the RE:NODE VDS line is an Intel i9-9900K in Germany with NVMe on a ZFS pool and full root access, from 8 GB and two vCPU up to 24 GB and six. Each one is prepared by hand, so it arrives within 24 hours rather than in a minute.

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

---

### SSH keys and sshd_config hardening

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/ssh-keys-and-hardening

Three commands cover the common case. `ssh-keygen -t ed25519 -a 100` makes a key. `ssh-copy-id deploy@203.0.113.10` installs it. `PasswordAuthentication no` in the server's configuration closes the door behind you. Do those three and your server can no longer be brute-forced, which removes the single largest category of attack against it, because a private key is not something anybody guesses.

Everything after that is about making keys pleasant to live with rather than making them stronger: one key that unlocks once a day instead of a passphrase typed forty times, a config file that turns a long command into a short name, a jump host that gets you to machines with no public address, and the handful of `sshd_config` settings that are worth changing as opposed to the forty that are copied between blog posts without explanation.

One rule underpins the whole post. **Never change SSH configuration without a working session already open**, and never close that session until a brand new connection has succeeded. Every lockout story begins with somebody skipping that.

#### Which key type, and why ed25519

| Type | Use it | Notes |
| --- | --- | --- |
| `ed25519` | Yes, by default | Small, fast, no parameters to get wrong |
| `ed25519-sk` | For a hardware token | Needs a FIDO2 key and OpenSSH 8.2 or newer |
| `rsa` | Only for compatibility | Use `-b 4096`. Still fine cryptographically |
| `ecdsa` | No reason to choose it | Works, but nothing recommends it over ed25519 |
| `dsa` | Never | Obsolete and removed from current OpenSSH |

An ed25519 public key is one short line, the private key is about 400 bytes, and there is no key size to choose because the curve fixes it. The `-b` flag is silently ignored for ed25519, which occasionally confuses people following an RSA tutorial.

One version detail explains a confusing failure. OpenSSH 8.8 disabled the `ssh-rsa` signature algorithm, which uses SHA-1, by default. That is the signature algorithm, not the key type: an RSA key still works against a modern server as long as both ends can negotiate `rsa-sha2-256` or `rsa-sha2-512`. What it means in practice is that a very old client or a very old RSA setup can suddenly be rejected by an updated server, and the error blames the key rather than the algorithm. If you meet that, generate an ed25519 key rather than re-enabling SHA-1.

#### Making and installing a key properly

Generate the key on the machine you sit at, never on the server. A private key that has been on a server is a private key that was on somebody else's computer.

```bash
$ ssh-keygen -t ed25519 -a 100 -C "davit@laptop-2026"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/davit/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
```

Three parts of that line are worth understanding rather than copying.

- `-a 100` sets the number of rounds used to derive the key that encrypts your private key file. It makes a stolen key file slower to brute-force and costs you a fraction of a second at unlock time.
- `-C` is a comment. Put the human and the machine in it. In three years, `authorized_keys` on a shared server is a list of comments and nothing else, and "which of these five keys belongs to the person who left" is a question you will be asked.
- **Give it a passphrase.** A key without one is a password file that anyone with read access to your laptop can copy and use forever. The agent, in the next section, means you type it once a day.

Install it with `ssh-copy-id`, which handles the permissions and the appending correctly:

```bash
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10
```

If password login is already disabled and you need to add a second key, append it through the session you still have open. The permissions matter more than people expect, because `sshd` ignores an `authorized_keys` file it considers unsafe and gives the client no clue why:

| Path | Mode | Owner |
| --- | --- | --- |
| `/home/deploy` | `755` or stricter, not group-writable | `deploy` |
| `/home/deploy/.ssh` | `700` | `deploy` |
| `/home/deploy/.ssh/authorized_keys` | `600` | `deploy` |
| `~/.ssh/id_ed25519` (your machine) | `600` | you |

When a key is refused and you cannot see why, ask the client and then the server:

```bash
$ ssh -v deploy@203.0.113.10          # which keys were offered, what was accepted
$ journalctl -u ssh -n 50             # on the server, with LogLevel VERBOSE
```

Roughly nine times in ten the answer is in the first output: the key you expected was never offered, because the agent had a different one, or `IdentityFile` pointed somewhere else.

#### The agent, so you type the passphrase once

`ssh-agent` holds decrypted private keys in memory and signs challenges on request, so your passphrase is entered once per session rather than once per connection.

```bash
$ eval "$(ssh-agent -s)"
$ ssh-add ~/.ssh/id_ed25519
$ ssh-add -l
256 SHA256:5xM8n... davit@laptop-2026 (ED25519)
```

Most desktops start an agent for you. On macOS, `ssh-add --apple-use-keychain` stores the passphrase in the keychain. On Windows, the OpenSSH Authentication Agent is a service that needs setting to start automatically before `ssh-add` will work. Password managers and `gpg-agent` can act as agents too, which is convenient and does not change any of the rules below.

Two settings in `~/.ssh/config` make it automatic:

~/.ssh/config:

```text
Host *
    AddKeysToAgent yes
    IdentitiesOnly yes
    ServerAliveInterval 30
    ServerAliveCountMax 3
```

`IdentitiesOnly yes` is the important one and it fixes a real failure. Without it, the client offers every key in the agent, in turn, and each offer counts as an authentication attempt. With five keys loaded and a server set to `MaxAuthTries 3`, you are disconnected with "Too many authentication failures" while holding the correct key. `IdentitiesOnly` tells the client to offer only the key named for that host.

**Agent forwarding deserves a warning.** `ssh -A` exposes your agent socket on the remote machine, and anyone with root there can use it to authenticate as you to anything your keys open. Do not use it habitually, and never towards a machine you do not administer. The right tool for reaching a second machine is `ProxyJump`, which keeps the authentication on your laptop.

#### The config file that saves the most time

`~/.ssh/config` turns a long command into a word, and it is where the good habits live.

~/.ssh/config:

```text
Host vds
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Host db
    HostName 10.0.0.20
    User deploy
    ProxyJump vds

Host *
    AddKeysToAgent yes
    IdentitiesOnly yes
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
```

`ssh vds` now works, and so do `scp file vds:/tmp/` and `rsync -a ./site/ vds:/var/www/`, because they all read the same file.

`ProxyJump` is the part worth adopting today. `ssh db` opens a connection through `vds` to a machine on a private address, with the authentication happening on your laptop at each hop and no key or agent socket ever landing on the intermediate host. It replaces every reason people used to have for agent forwarding.

`ControlMaster` reuses one TCP connection for subsequent sessions to the same host, so the second and third `ssh vds` are instant and do not re-authenticate. It is a large quality-of-life improvement on a slow link and it is not supported by the Windows OpenSSH client. `ControlPersist 10m` keeps the shared connection alive for ten minutes after the last session closes.

`ServerAliveInterval 30` stops a home router or a middlebox from silently dropping an idle session, which is the cause of most connections that freeze rather than closing.

#### Hardening sshd

These go on the server. On Debian and Ubuntu, note that `/etc/ssh/sshd_config` starts with `Include /etc/ssh/sshd_config.d/*.conf`, and **sshd uses the first value it obtains for a keyword**, so a file in that directory beats the main config below it. Check what is already there before writing anything, as described in [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds).

| Directive | Set to | Why |
| --- | --- | --- |
| `PasswordAuthentication` | `no` | Removes brute force as a category |
| `KbdInteractiveAuthentication` | `no` | The other way a password can be accepted |
| `PermitRootLogin` | `no` | Or `prohibit-password` if automation needs root |
| `PubkeyAuthentication` | `yes` | The default, worth stating |
| `AuthenticationMethods` | `publickey` | Makes the intent explicit and enforced |
| `AllowUsers` or `AllowGroups` | Your accounts | Nothing else can even try |
| `MaxAuthTries` | `3` | Fewer offers per connection |
| `LoginGraceTime` | `20` | Unauthenticated sockets do not linger |
| `X11Forwarding` | `no` | Nothing on a server needs it |
| `PermitEmptyPasswords` | `no` | Default, and worth being certain of |
| `ClientAliveInterval` | `300` | Reaps dead sessions holding locks |
| `LogLevel` | `VERBOSE` | Logs the fingerprint of the key that logged in |

`LogLevel VERBOSE` is underrated. It records which key authenticated, by fingerprint, on every login. After an incident that single line is the difference between knowing whose credential was used and guessing. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) covers retaining it, and [what to do when your server is hacked](https://www.renode.space/blog/what-to-do-when-your-server-is-hacked) covers what you will want it for.

`Match` blocks apply settings to a subset, and they run to the end of the file or the next `Match`, so put them last:

/etc/ssh/sshd_config.d/10-hardening.conf:

```text
PasswordAuthentication no
PermitRootLogin no
AllowGroups ssh-users

Match Group sftp-only
    ChrootDirectory /srv/sftp/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
```

Always validate before restarting, and always from a session you already have:

```bash
$ sshd -t && systemctl restart ssh
```

What is not worth doing: changing the port (a scan finds it in seconds, though it does quieten your logs), disabling every cipher a modern client would negotiate anyway, or pasting a forty-line `sysctl` block you cannot explain. What is worth adding is [fail2ban](https://www.renode.space/blog/fail2ban-guide) if anything on the machine still accepts a password, and a firewall that allows SSH only from the addresses you use - [the ufw guide](https://www.renode.space/blog/ufw-firewall-guide) has the syntax.

#### Restricting what a key may do

A key does not have to be a general-purpose login. Options at the start of a line in `authorized_keys` apply to that key only, and they are how a backup job or a deploy hook gets exactly the access it needs.

~/.ssh/authorized_keys:

```text
restrict,from="203.0.113.0/24" ssh-ed25519 AAAAC3Nza... davit@laptop-2026
restrict,command="/usr/local/bin/backup-receive" ssh-ed25519 AAAAC3Nza... backup@nas
```

The options worth knowing:

- `restrict` (OpenSSH 7.2 and later) disables everything optional at once: port forwarding, agent forwarding, X11, a tty, and user environment variables. Start from it and add back only what is needed, with `pty` or `port-forwarding`.
- `from="..."` limits the key to a source address or range. For a fixed office or a known server, this is a strong, free control.
- `command="..."` forces that command no matter what the client asked for. The client's original request is available to the script in `SSH_ORIGINAL_COMMAND`, which is how restricted `rsync` or `git` access is built.
- `expiry-time="20270101"` (OpenSSH 8.2 and later) stops the key working after a date. Useful for a contractor.

Review `authorized_keys` on every server twice a year. It is an append-only file in practice: keys go in when somebody joins and nobody remembers to take them out. That review is the same exercise as auditing panel subusers, and [subusers and least privilege](https://www.renode.space/blog/subusers-and-least-privilege) makes the case on the account side.

#### Host keys, known_hosts and trusting the server

Authentication is mutual. The server proves who it is with its host key, and your client remembers it in `~/.ssh/known_hosts`. The first connection is trust on first use: you are shown a fingerprint and asked to accept it, and almost everybody types yes without looking, which makes the whole scheme decorative.

Do it properly once per machine. On the server, from the provider's console:

```bash
$ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:5xM8nQ... root@web-01 (ED25519)
```

Compare that string with what your client shows on the first connection. It takes ten seconds and it is the only moment the check is possible.

After a rebuild the host key changes and you get a loud warning about a possible attack. It is usually legitimate, and the fix is to forget the old entry:

```bash
$ ssh-keygen -R 203.0.113.10
```

Only do that when you know why the key changed. A host key that changes on a machine you did not touch is exactly the event the warning exists for. Modern OpenSSH also rotates host keys for you: with `UpdateHostKeys` on, a server that adds a new host key type can hand it to clients that have already authenticated, so `known_hosts` stays current without warnings.

#### Two-factor, certificates, and when they are worth it

Two more layers exist and most small setups do not need them.

**Two-factor on top of a key.** `AuthenticationMethods publickey,keyboard-interactive` requires a key and then a second factor through PAM, typically a TOTP code. It is genuinely stronger for a shared production machine. It also breaks unattended jobs, so combine it with a `Match` block that exempts the automation account, and be sure you understand what happens when the PAM module misbehaves before you rely on it.

**SSH certificates.** Instead of copying public keys to every server, a certificate authority signs short-lived user certificates, and servers trust the authority with `TrustedUserCAKeys`. Joining and removing people becomes a signing decision rather than an edit on thirty machines, and certificates expire on their own.

```bash
$ ssh-keygen -s ca_key -I davit -n deploy -V +8w ~/.ssh/id_ed25519.pub
```

For one person with two servers this is overhead with no benefit. Past roughly five people or twenty machines it becomes the obviously correct answer, and the crossover is where `authorized_keys` edits start being forgotten.

#### Locked out: what actually saves you

In order of how much you will wish you had it:

1. **The session you kept open.** Undo the change, restart `sshd`, breathe.
2. **`sshd -t` before every restart.** It catches typos, which is what most lockouts are.
3. **Your provider's console or rescue mode.** A VDS with full root access has one; it is slow and it works. Find out where it is before you need it, not during.
4. **A second key from a second machine**, installed while things worked. A phone with an SSH client counts.
5. **A second account in `AllowUsers`** with its own key, used for nothing else.

A managed game, app or web server is a different situation entirely: there is no SSH daemon to lock yourself out of, file access is over SFTP with credentials issued per server, and the console lives in the panel. That is the trade [VDS or a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) describes - a VDS gives you every setting in this post, and the responsibility for all of them.

#### FAQ

##### Should my key have a passphrase if only I use this laptop?

Yes. The passphrase protects the key file, not the connection, and the file is what gets copied when a laptop is stolen or when malware goes looking for `~/.ssh`. With an agent you type it once per session, so the cost is close to zero and the benefit is that a stolen file is not immediately a stolen server.

##### Can I use the same key on several servers?

Yes, that is normal and fine: the private key never leaves your machine, and the public key is public by design. Use a different key per person, not per server. Separate keys per server only become useful when you want the option of revoking access to one machine without touching the others.

##### What if I lose my private key?

You lose access, and nothing else is at risk. Get in another way - a second key, the provider's console, a colleague with access - and append a new public key to `authorized_keys`, then remove the old line. This is why a break-glass key or a console you know how to reach is worth arranging in advance.

##### Is it safe to email or paste a public key?

Yes. The public key is meant to be published; it is in `authorized_keys` on every server you use. What must never leave your machine is the file without the `.pub` extension. If a private key is ever pasted anywhere, treat it as burnt and generate a new pair.

##### Does disabling password authentication lock out my other tools?

It locks out anything that authenticates with a password, including some file transfer clients configured that way. Point them at the key instead - every mainstream SFTP client supports keys - and check any automation before you make the change rather than after.

##### Do I still need fail2ban with keys only?

Not for SSH, strictly. Failed key attempts cannot succeed, so the benefit is log volume rather than security. It still earns its place in front of web logins, mail and anything else that accepts a password, and it costs very little to leave running.

RE:NODE VDS plans give full root access on Intel i9-9900K hardware in Germany, so `sshd_config` is yours to write and every setting in this post applies. Each machine is prepared by hand rather than by a script, and is ready within 24 hours of the order.

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

---

### systemd services for your apps

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/systemd-services-for-your-apps

An application started from an SSH session dies with the session. An application started with `nohup ... &` survives the session and dies with the reboot. An application inside `screen` or `tmux` survives both until the day somebody kills the wrong window, and it leaves you with no logs anybody else can find. A `systemd` unit is twelve lines of text that fixes all three at once: the process starts at boot, restarts when it crashes, runs as the right user with the right environment, and sends everything it prints to a log you can query by time and severity.

This is the part of running your own server that pays back fastest, and it is genuinely small. Below is a working unit file, then every directive worth knowing in it, then the two things that actually go wrong - the start limit and the environment file - followed by logs, hardening, several instances of one service, and timers as a better cron.

#### What systemd gives you that tmux and nohup do not

- **It starts at boot**, before anyone logs in, in a defined order relative to the network and the filesystems it needs.
- **It restarts on failure**, with a delay you set and a limit so that a permanently broken service does not spin.
- **It owns the whole process tree.** Everything the service spawns lives in one control group, so stopping the service stops the children too rather than leaving orphans holding a port.
- **It runs as a chosen user** with a chosen working directory, umask and environment, none of which depend on whose shell started it.
- **It centralises output.** Anything written to standard output or standard error lands in the journal, tagged with the unit, queryable by time, and rotated without you writing a logrotate file.
- **It can drop privileges and restrict the kernel surface** with a dozen one-line options, which is the cheapest hardening available anywhere.

What it does not do is know whether your application is working. A wedged process that no longer answers requests satisfies `systemd` perfectly. Liveness is a health check plus something that acts on it, which is a different job - [graceful shutdown and health checks](https://www.renode.space/blog/graceful-shutdown-and-health-checks) covers the application half.

#### Your first unit file

/etc/systemd/system/notes.service:

```ini
[Unit]
Description=Notes web application
Documentation=https://example.com/docs
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=notes
Group=notes
WorkingDirectory=/srv/notes
EnvironmentFile=-/etc/notes/notes.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node /srv/notes/server.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
```

```bash
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now notes
$ systemctl status notes
$ journalctl -u notes -f
```

`daemon-reload` after every edit, always - systemd caches unit files and will otherwise run the old one while you read the new one. `enable` creates the symlink that starts it at boot; `--now` also starts it immediately. Those two are separate concepts and `systemctl start` without `enable` is the classic reason a service that "works" is missing after a reboot.

Three details in that file that are easy to get wrong. `ExecStart` must be an absolute path - `node` will not be found, `/usr/bin/node` will. It is not run through a shell, so pipes, globs, `&&` and variable expansion do not work; if you need them, wrap the command in `/bin/sh -c '...'` deliberately. And units placed in `/etc/systemd/system/` override same-named units shipped by packages in `/usr/lib/systemd/system/`, which is why your own services belong in the first directory and package units should be adjusted with a drop-in rather than edited.

That drop-in mechanism is worth learning on day one:

```bash
$ sudo systemctl edit notes      # writes /etc/systemd/system/notes.service.d/override.conf
$ systemctl cat notes            # shows the unit plus every drop-in, merged
```

#### The directives that matter

| Directive | Section | What it does |
| --- | --- | --- |
| `After=` / `Before=` | `[Unit]` | Ordering only. Does not pull anything in |
| `Wants=` / `Requires=` | `[Unit]` | Pulls the other unit in. `Requires` also fails with it |
| `Type=` | `[Service]` | How systemd decides the service has started |
| `ExecStart=` | `[Service]` | Absolute path plus arguments, no shell |
| `ExecStartPre=` | `[Service]` | Runs first; a failure aborts the start |
| `ExecStop=` | `[Service]` | Optional. Without it, a signal is sent |
| `User=` / `Group=` | `[Service]` | Drop privileges. Never leave a network service as root |
| `WorkingDirectory=` | `[Service]` | The process's own directory |
| `EnvironmentFile=` | `[Service]` | A `KEY=value` file. Prefix with `-` to make it optional |
| `Restart=` | `[Service]` | See the next section |
| `RestartSec=` | `[Service]` | Delay before restarting. Default is 100ms |
| `TimeoutStopSec=` | `[Service]` | How long a clean shutdown may take before `SIGKILL` |
| `KillSignal=` | `[Service]` | Default `SIGTERM`. Some software wants `SIGINT` |
| `MemoryMax=` / `CPUQuota=` | `[Service]` | Control-group limits, e.g. `1G` and `150%` |
| `WantedBy=` | `[Install]` | Which target starts it. Nearly always `multi-user.target` |

The ordering pair is the one people misunderstand. `After=postgresql.service` says "if both are being started, start me second". It does not start PostgreSQL. `Wants=postgresql.service` starts it and carries on if it fails; `Requires=` starts it and refuses to start if it fails. Use `Wants` plus `After` for most dependencies and keep `Requires` for the cases where running without the other unit is genuinely pointless.

`After=network-online.target` deserves a note as well, because `network.target` is not what most people want. `network.target` means the network stack is being brought up; `network-online.target` means an address has actually been configured, and it only works if you also list it in `Wants=` and the distribution's wait-online service is enabled. If your service binds a specific public address at startup, you need the second. If it binds `0.0.0.0`, the first is fine.

`KillSignal=SIGINT` looks obscure until you host a game. Several game servers save their world on `SIGINT` and simply die on `SIGTERM`, so a unit with the default kill signal loses everything since the last autosave on every restart. Check what your software expects before you rely on the restart button.

#### Type, Restart and the start limit

`Type=` tells systemd when the service counts as started, which decides when dependent units are allowed to begin.

| Type | Use it when |
| --- | --- |
| `simple` | The process runs in the foreground. The default, and right for most apps |
| `exec` | Like `simple`, but start fails if the binary cannot be executed |
| `forking` | The program daemonises itself. Needs `PIDFile=` |
| `oneshot` | A script that runs and exits. Pair with `RemainAfterExit=yes` |
| `notify` | The program calls `sd_notify` when it is genuinely ready |

If you wrote the application, run it in the foreground and use `simple` or `exec`. Do not add a daemonising flag so you can use `forking`; it is more moving parts for no gain, and modern process managers all expect the foreground.

`Restart=` has more values than people use, and two of them cover almost everything:

| Value | Behaviour |
| --- | --- |
| `no` | Never restart. The default |
| `on-failure` | Restart on a non-zero exit, a signal, a timeout. Not on a clean exit |
| `always` | Restart whatever happened, including a clean exit |
| `on-abnormal` | Signals, timeouts and watchdog failures only |

`on-failure` for anything that could legitimately finish; `always` for a server that should never exit at all. Set `RestartSec=5` alongside it, because the default of 100 milliseconds turns a crash loop into a busy loop.

Then the trap. systemd refuses to restart a service more than `StartLimitBurst` times within `StartLimitIntervalSec` - by default five starts in ten seconds - and when it hits that limit it stops trying entirely and reports:

```text
notes.service: Start request repeated too quickly.
notes.service: Failed with result 'start-limit-hit'.
```

This is systemd doing the right thing: a service that dies instantly five times running is broken, not unlucky. What surprises people is that the service then stays dead until a human intervenes, even after the underlying problem is fixed. The fix is `sudo systemctl reset-failed notes` followed by a start, and the tuning is in `[Unit]`:

```ini
[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5
```

Widening that window rather than raising the burst is usually right. If your service takes ten seconds to fail, five failures already spans fifty seconds, and the default interval never triggers - which is why the message appears mostly with services that fail on a missing file in under a second. The same pattern in a managed context, and what a host does about it, is in [why your game server keeps restarting](https://www.renode.space/blog/why-your-game-server-keeps-restarting).

#### Environment variables and secrets

There are three ways to get configuration into a unit, and they layer.

```ini
Environment=NODE_ENV=production
Environment="GREETING=hello there"
EnvironmentFile=-/etc/notes/notes.env
```

`Environment=` is for non-secret values you are happy to have in a world-readable unit file. `EnvironmentFile=` points at a file of `KEY=value` lines, and that is where secrets belong, because the file can be owned by root and mode `0600` while the unit stays readable.

```bash
$ sudo install -d -m 0750 -o root -g notes /etc/notes
$ sudo install -m 0640 -o root -g notes /dev/null /etc/notes/notes.env
$ sudo nano /etc/notes/notes.env
```

/etc/notes/notes.env:

```env
DATABASE_URL=postgres://notes:secret@127.0.0.1:5432/notes
SESSION_SECRET=a-long-random-string
PORT=3000
```

Four rules for that file, all of which people break once:

- **No `export`.** It is not a shell script. `export FOO=bar` sets a variable literally called `export FOO`.
- **No shell expansion.** `PATH=$PATH:/opt/bin` stores the four characters `$PATH`, not your path.
- **Quotes are stripped**, so `FOO="bar"` gives `bar`. That means a value that genuinely needs quotes needs care.
- **The leading `-`** on `EnvironmentFile=-/etc/notes/notes.env` makes a missing file non-fatal. Without it, a typo in the path stops the service from starting at all - which is sometimes exactly what you want for a file holding the database password.

Check what the service actually received rather than assuming:

```bash
$ systemctl show notes -p Environment
$ sudo cat /proc/$(systemctl show notes -p MainPID --value)/environ | tr '\0' '\n'
```

That second command is also the reason to think about who can read the process environment on a shared box. [Environment variables and secrets](https://www.renode.space/blog/environment-variables-and-secrets) covers the wider question, including what should not be an environment variable at all.

#### journalctl: where the output went

Anything the service writes to standard output or standard error goes to the journal, tagged with the unit name. No redirection, no log file, no logrotate configuration.

```bash
$ journalctl -u notes                      # everything, oldest first
$ journalctl -u notes -f                   # follow, like tail -f
$ journalctl -u notes -n 200 --no-pager    # last 200 lines, printable
$ journalctl -u notes --since "1 hour ago"
$ journalctl -u notes --since "2026-09-20 18:00" --until "2026-09-20 19:00"
$ journalctl -u notes -p err               # errors and worse only
$ journalctl -u notes -b                   # this boot only
$ journalctl -u notes -o cat               # message text, no timestamps
```

The `-p` filter takes the syslog severities, so `-p warning` gives warnings and above. `-b -1` gives the previous boot, which is how you find out what happened immediately before an unplanned reboot.

One thing to check on a fresh server: whether the journal survives a reboot at all. On several distributions it is stored in `/run` and vanishes when the machine restarts, which makes post-mortem debugging impossible.

```bash
$ journalctl --disk-usage
$ sudo mkdir -p /var/log/journal
$ sudo systemd-tmpfiles --create --prefix /var/log/journal
$ sudo systemctl restart systemd-journald
```

Then cap it, because an unbounded journal on a chatty service is a slow way to fill a disk:

/etc/systemd/journald.conf:

```ini
Storage=persistent
SystemMaxUse=500M
MaxRetentionSec=1month
```

`sudo journalctl --vacuum-time=14d` prunes an existing journal immediately. And if you see `Suppressed N messages from notes.service` in the log, that is journald's rate limiter - by default a few thousand messages in a short window - which means your service is far too noisy rather than that journald is broken. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) is about deciding what should be in there in the first place.

#### Locking the service down

Each of these is one line and most cost nothing. Add them a few at a time and test, because a couple of them do break real software.

```ini
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/srv/notes/uploads
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes
```

`ProtectSystem=strict` mounts the entire filesystem read-only for this service except `/dev`, `/proc` and `/sys`, so every directory it legitimately writes to has to be listed in `ReadWritePaths=`. That is the single most valuable line here and the one most likely to produce a startup failure the first time, which is the point: it forces you to know what your application writes.

Two more worth knowing. `DynamicUser=yes` creates and destroys a system user around the service so you never manage one, and it pairs with `StateDirectory=notes`, which creates `/var/lib/notes` with the right ownership automatically. And `AmbientCapabilities=CAP_NET_BIND_SERVICE` lets an unprivileged service bind port 80 or 443 without running as root - though behind [an nginx reverse proxy](https://www.renode.space/blog/nginx-reverse-proxy-guide) you do not need it at all.

The one to be careful with is `MemoryDenyWriteExecute=yes`, which breaks any runtime with a just-in-time compiler. Node, modern Java and several Python extensions will refuse to start. Measure the result of whatever you choose:

```bash
$ systemd-analyze security notes.service
```

That prints a line per setting and an overall exposure score. Treat it as a checklist rather than a target - going from "unsafe" to "medium" is most of the available benefit, and the last few points usually cost more than they return.

#### Template units and timers

A template unit runs many copies of one configuration. The file name ends in `@`, and `%i` is replaced by whatever follows the `@` when you start it.

/etc/systemd/system/valheim@.service:

```ini
[Unit]
Description=Valheim server (%i)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=games
WorkingDirectory=/srv/valheim/%i
EnvironmentFile=/etc/valheim/%i.env
ExecStart=/srv/valheim/%i/valheim_server.x86_64 -nographics -batchmode
KillSignal=SIGINT
TimeoutStopSec=90
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
```

```bash
$ sudo systemctl enable --now valheim@midgard
$ sudo systemctl enable --now valheim@testworld
$ journalctl -u valheim@midgard -f
```

Two servers, one unit file, separate environment files, separate directories, separate logs. `KillSignal=SIGINT` and the long `TimeoutStopSec` are there because that is a game server that saves on shutdown and needs to be allowed to finish. [Several game servers on one VDS](https://www.renode.space/blog/multiple-game-servers-on-one-vds) is the full version of this pattern, including ports and users.

Timers replace cron, and the case for switching is not aesthetic. A timer is two files - a `oneshot` service and a timer that triggers it - and the service gets everything above: a user, an environment file, resource limits, hardening, and output in the journal instead of an email nobody reads.

/etc/systemd/system/db-backup.service:

```ini
[Unit]
Description=Nightly database dump

[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/db-backup.sh
```

/etc/systemd/system/db-backup.timer:

```ini
[Unit]
Description=Run the database dump at 04:30

[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
```

```bash
$ sudo systemctl enable --now db-backup.timer
$ systemctl list-timers --all
$ systemd-analyze calendar "*-*-* 04:30:00"
$ sudo systemctl start db-backup.service     # run it now, on demand
```

`Persistent=true` is the feature cron does not have: if the machine was off at 04:30, the job runs once shortly after it comes back rather than being silently skipped. `RandomizedDelaySec` spreads several machines so they do not all hit the same backup target at once. And `systemd-analyze calendar` tells you the next time an expression will fire, which beats waiting until tomorrow to find out you wrote it wrong. If you prefer cron's syntax, [cron expressions explained](https://www.renode.space/blog/cron-expressions-explained) covers the five fields - and note that a timer is only as good as the script it runs, so read [backups that actually restore](https://www.renode.space/blog/backups-that-actually-restore) before trusting either.

#### When it will not start

```bash
$ systemctl status notes          # state, last lines, exit code
$ journalctl -u notes -n 50       # the actual error
$ systemctl cat notes             # the unit as systemd sees it, plus drop-ins
$ systemd-analyze verify /etc/systemd/system/notes.service
$ systemctl list-units --failed   # everything broken on this box
```

**`status=203/EXEC`.** systemd could not execute `ExecStart`. Wrong path, not executable, or a script with a missing or wrong shebang. Run the exact command by hand as the service user.

**`status=200/CHDIR`.** `WorkingDirectory` does not exist, or the service user cannot enter it.

**`Start request repeated too quickly`.** The start limit. Fix the real problem, then `systemctl reset-failed`.

**It starts by hand and fails at boot.** A dependency was not ready. Add `After=` for the thing it needs, and `Wants=network-online.target` if it binds a specific address.

**It runs but cannot write anything.** `ProtectSystem=strict` without the matching `ReadWritePaths=`, or plain file ownership. The journal will name the path.

**Environment variables are empty.** The file path is wrong and you used the `-` prefix, so the failure is silent. Check with `systemctl show notes -p Environment`.

**It works in a shell and not as a service.** Almost always the environment: your login shell has a `PATH`, a `HOME`, a language setting and possibly a version manager that the service does not. Set what you need explicitly in the unit rather than inheriting it by luck.

**Changes to the unit do nothing.** You forgot `daemon-reload`.

On RE:NODE's managed plans none of this is yours to write: the panel starts and stops the process, a watcher polls every two minutes for a server that went offline or whose uptime went backwards, and three unrequested restarts in an hour raise a warning and open a ticket automatically. The Schedules tab takes a cron expression and runs ordered tasks - a console command, a backup, a power action - which is the managed equivalent of the timer above. On a VDS you have root and you build the same behaviour yourself out of the pieces in this post, which is more work and considerably more control. [Choosing between a VDS and a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel) is the honest comparison, and [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds) is what to do before any of it.

#### FAQ

##### Do I still need pm2 or supervisor?

Not for keeping a process alive - systemd does that better, at a lower level, with logs and dependencies included. Process managers still earn their place for features systemd does not have, such as running several instances of a Node app across cores with zero-downtime reloads. Running both is a common and avoidable mistake; [pm2 or a hosting panel](https://www.renode.space/blog/pm2-vs-a-hosting-panel) goes through the overlap.

##### Should I run services as root?

No. Create a system user with no login shell, set `User=` in the unit, and give it write access only to the directories it needs. If the service must bind a low port, use `AmbientCapabilities=CAP_NET_BIND_SERVICE` or put a proxy in front of it rather than reaching for root.

##### What is the difference between `systemctl start` and `systemctl enable`?

`start` runs it now. `enable` makes it start at boot. They are independent, and a service you started but never enabled is missing after the next reboot. `enable --now` does both.

##### Can I use systemd to run Docker containers?

Yes, and it is a reasonable pattern when a container needs to start after a mounted filesystem or in a specific order. Write a unit that runs `docker compose up -d` as `Type=oneshot` with `RemainAfterExit=yes`, or use Docker's own restart policies for the simple case - [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) covers those.

##### Why do my logs disappear after a reboot?

The journal is volatile on some distributions, stored under `/run`. Create `/var/log/journal`, set `Storage=persistent` in `journald.conf`, restart `systemd-journald`, and cap the size so it cannot fill the disk.

##### How do I run a service as my own user without root?

`systemctl --user` manages units in `~/.config/systemd/user/`. By default those stop when you log out, so run `loginctl enable-linger yourname` once to keep them running. It is convenient for personal tools and not the right place for anything the machine's uptime depends on.

Unit files, timers and the journal need a machine you actually own: the RE:NODE VDS line is an Intel i9-9900K in Germany with NVMe on a ZFS pool and full root access, from 8 GB and two vCPU up to 24 GB and six. Each one is prepared by hand, so it arrives within 24 hours rather than in a minute.

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

---

### UFW firewall: default deny, ports and Docker

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/ufw-firewall-guide

A firewall on a single server is one decision, repeated: nothing gets in unless you said so. UFW - the Uncomplicated Firewall - is the tool that makes that decision cheap to express on Debian and Ubuntu, and five commands set it up. Everything interesting comes afterwards: the rule for a game server's UDP pair, what `limit` actually does to SSH, why a rule you added is being ignored, and the fact that publishing a Docker port walks straight past every rule in this post. That last one catches people who did everything else correctly.

If you want the short version and you are sitting on a new box right now: `sudo ufw default deny incoming`, `sudo ufw default allow outgoing`, `sudo ufw allow OpenSSH`, `sudo ufw enable`. Then read the rest before you add anything else, because the order in which you do those four things is the difference between a locked-down server and a server you cannot log into.

#### What UFW is, and what it is not

UFW is not a firewall. The firewall is netfilter, inside the Linux kernel, and it has been there the whole time with an empty policy. UFW is a command-line front end that writes rules into it - as iptables rules, which on current Debian and Ubuntu are translated to nftables by the `iptables-nft` backend. When you run `ufw allow 443/tcp` you are not inventing anything; you are adding one line to a chain called `ufw-user-input` and letting UFW manage everything around it.

That surrounding structure is the real product. A hand-written iptables ruleset has to remember to permit loopback traffic, to accept packets belonging to connections you opened yourself, to handle ICMP sensibly, and to do all of it again for IPv6. UFW ships that as `before.rules` and `after.rules`, puts your rules in the middle, and keeps the two address families in step. The files live in `/etc/ufw/`, and the ones you will eventually care about are `/etc/ufw/before.rules`, `/etc/ufw/after.rules` and `/etc/default/ufw`.

What UFW is not:

- **It is not protection against volumetric attacks.** A flood large enough to fill the uplink has already cost you the bandwidth by the time the kernel drops it. Filtering that has to happen upstream of the machine. [DDoS attacks on game servers explained](https://www.renode.space/blog/ddos-attacks-on-game-servers-explained) is blunt about what can and cannot be done at each layer.
- **It is not an application firewall.** It matches addresses, ports and protocols. It has no idea whether a request to port 443 is a login attempt or a login attempt repeated nine thousand times - that is [fail2ban](https://www.renode.space/blog/fail2ban-guide) reading your logs, working alongside UFW rather than instead of it.
- **It is not a reason to run something insecure.** A closed port in front of an unpatched service buys you time, not safety.
- **It does not filter traffic between containers**, and it does not necessarily filter traffic into them either. See the Docker section, which is the longest one here for a reason.

#### The first five minutes on a new box

Ubuntu ships UFW installed and inactive. Debian needs `apt install ufw`. The order below matters: set the defaults, allow your way back in, and only then switch it on.

```bash
$ sudo apt update && sudo apt install -y ufw
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
$ sudo ufw allow OpenSSH
$ sudo ufw enable
```

> [!WARNING]
> `ufw enable` applies the default deny immediately, including to the SSH session you are typing in. If you have not allowed your SSH port first, you are out, and on most providers the only way back is a console session through the control panel. If you moved SSH off port 22, allow the port you moved it to, not `OpenSSH`, which is a profile for port 22.

UFW prompts before it cuts you off - `Command may disrupt existing ssh connections. Proceed with operation (y|n)?` - and that prompt is the last warning you get. The setting persists across reboots: `ufw enable` writes `ENABLED=yes` into `/etc/ufw/ufw.conf` and enables the `ufw` systemd unit, so the rules are back before the network is.

Check what you have:

```bash
$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         --          ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
```

The `Default:` line is the one to read. `deny (incoming)` is the whole point; `allow (outgoing)` is the sane default for a server you control, because locking down outbound traffic on a box that has to fetch packages, pull images and talk to APIs is a project rather than a command. `disabled (routed)` means UFW is not filtering forwarded traffic, which is exactly the gap Docker drives through later.

#### Writing rules: both syntaxes

UFW has a short form for the common case and a long form for everything else. They produce the same kind of rule.

```bash
# Short form: port, optional protocol, optional application profile
$ sudo ufw allow 443/tcp
$ sudo ufw allow 25565
$ sudo ufw allow 'Nginx Full'

# Long form: source, destination, port, protocol
$ sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
$ sudo ufw allow from 10.0.0.0/8 to any port 5432 proto tcp
$ sudo ufw deny from 198.51.100.0/24
```

A few points that are not obvious from the help text:

- **Omitting the protocol opens both.** `ufw allow 25565` creates a TCP rule and a UDP rule. Be specific unless you mean both; extra open UDP ports are how a server ends up reflecting somebody else's attack traffic.
- **Every rule is created for IPv4 and IPv6** unless you name an address family by giving an address. If the box has a public IPv6 address - check with `ip -6 addr` - then a v4-only rule leaves a v4-only hole in an otherwise open door.
- **`deny` drops silently, `reject` answers.** A dropped packet makes the port look like it belongs to a machine that is switched off; a rejected one gets an ICMP port-unreachable or a TCP reset, which is faster and more honest for internal services. Use `deny` facing the internet and `reject` where a quick failure is more useful than a stealthy one.
- **Comments are supported and worth the typing.** `sudo ufw allow 8443/tcp comment 'matrix bridge'` shows up in `ufw status` and saves you from the six-month-old rule nobody can explain.

Application profiles come from `/etc/ufw/applications.d`, and packages drop them there when you install them. `ufw app list` shows what is available; `ufw app info 'Nginx Full'` prints the ports behind a name. They are a convenience, not a feature - `Nginx Full` is `80,443/tcp` and nothing more.

#### Rules for what you will actually run

| What you are running | Rule | Note |
| --- | --- | --- |
| SSH | `ufw limit 22/tcp` | Rate-limited, see below |
| A website through nginx | `ufw allow 'Nginx Full'` | Port 80 must stay open for ACME |
| Minecraft Java | `ufw allow 25565/tcp` | TCP only, despite the folklore |
| Valheim | `ufw allow 2456:2457/udp` | Game port and query port, both UDP |
| A Source game | `ufw allow 27015/udp` | Add `27015/tcp` only if you use RCON |
| PostgreSQL | `ufw allow from 203.0.113.10 to any port 5432 proto tcp` | Never `allow 5432` on its own |
| A Node app behind a proxy | no rule at all | Bind it to `127.0.0.1` instead |

Port ranges use a colon and **must** name a protocol: `ufw allow 27015:27020/udp` works, `ufw allow 27015:27020` is rejected. The long form spells the same thing out as `ufw allow proto udp from any to any port 27015:27020`.

The last two rows are the ones worth internalising. A database or an application server that only ever talks to something on the same machine should be bound to loopback, at which point no firewall rule is needed because the packet can never arrive from outside. A firewall rule protecting a service that should not have been listening publicly is a second lock on a door you left open; [firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter) makes the same argument from the other direction, and [game server ports explained](https://www.renode.space/blog/game-server-ports-explained) covers why games so often need a pair of ports rather than one.

For the specific question of which port each game wants, the pattern is nearly always a game port plus a query port one or two numbers above it, both UDP. Open the pair. A TCP-only rule on a UDP game produces the single most common support ticket in hosting: the server starts fine, the log looks healthy, and nobody can connect or find it in the browser.

#### Order, deletion and the `limit` rule

UFW evaluates rules in order and **stops at the first match**. This is the source of most "my rule does nothing" confusion. If you add a blanket allow for port 80 and then add a deny for one address on port 80, the deny is below the allow and is never reached.

```bash
$ sudo ufw status numbered
Status: active

     To                         Action      From
     --                         --          ----
[ 1] 22/tcp                     LIMIT IN    Anywhere
[ 2] 80,443/tcp (Nginx Full)    ALLOW IN    Anywhere
[ 3] 25565/tcp                  ALLOW IN    Anywhere

$ sudo ufw insert 1 deny from 198.51.100.7
$ sudo ufw delete 4
$ sudo ufw delete allow 25565/tcp
```

`insert N` puts a rule at a specific position, which is how you get a block in front of an allow. `delete N` removes by number - and renumbers everything below it, so delete from the bottom up or re-run `status numbered` between deletions. `delete` followed by the original rule text also works and is safer in a script.

`ufw limit` deserves its own paragraph because it is the most useful thing in the tool and the least understood. `ufw limit 22/tcp` allows the port but denies a source address that has opened six or more connections in the last thirty seconds. On an internet-facing SSH port that removes essentially all of the automated password-guessing noise without you configuring anything. Two caveats: it counts connections, not failed logins, so a legitimate burst of parallel `scp` jobs can trip it, and it is IPv4-only in older UFW releases - check `ufw status` for a `LIMIT IN` entry on the `(v6)` line before assuming both are covered.

For anything more selective, keep UFW for the coarse decisions and put [fail2ban](https://www.renode.space/blog/fail2ban-guide) on top for the log-reading ones. And on a server with [SSH keys and password authentication disabled](https://www.renode.space/blog/ssh-keys-and-hardening), the guessing attempts become noise rather than risk in the first place.

#### The Docker problem

This is the part to read twice. **Docker bypasses UFW.** If you run a container with `-p 8080:80`, port 8080 is reachable from the internet even though `ufw status` says deny and shows no rule for it.

The reason is structural rather than a bug. UFW's rules live in the `INPUT` chain, which handles packets destined for the host itself. A published container port is destination-NATed in `PREROUTING` and then traverses the `FORWARD` chain on its way to the container's namespace - it never touches `INPUT`. Docker installs its own rules in `FORWARD` to permit exactly that, and Docker's rules are evaluated before UFW gets a say. Your default deny is not wrong; it is being asked about a different journey.

Four ways out, in the order you should consider them:

1. **Publish to loopback.** `-p 127.0.0.1:8080:80` binds the published port to the loopback interface, so nothing off the machine can reach it, and a reverse proxy running on the host still can. This is correct on every Docker version, needs no extra tooling, and is the right default for anything that sits behind [an nginx reverse proxy](https://www.renode.space/blog/nginx-reverse-proxy-guide).
2. **Do not publish at all.** Containers on the same user-defined network reach each other by service name on the container port. Only the front door needs a published port - which is the shape [Docker Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) builds around.
3. **Use the `DOCKER-USER` chain.** Docker provides this chain specifically as a hook, and it is evaluated before Docker's own rules, so it is the supported place for your own filtering of container traffic.
4. **Use `ufw-docker`**, a community script that adds forward-chain handling so `ufw route allow` behaves the way you expect for containers. It works, it is widely used, and it is one more moving part to understand before you rely on it.

A minimal `DOCKER-USER` rule, restricting published container ports to one address, looks like this:

```bash
$ sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
$ sudo iptables -L DOCKER-USER -n --line-numbers
```

Two footnotes. Rules added with `iptables` by hand do not survive a reboot unless you persist them - `iptables-persistent` on Debian and Ubuntu, or a small systemd unit as described in [systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps). And Docker's behaviour in this area has been tightened in recent major releases, particularly around direct access to container addresses, so the exact symptoms vary with your version. Rather than track which release does what, adopt habit one: publish to `127.0.0.1` unless the port genuinely belongs to the public. That is correct everywhere.

#### IPv6, forwarding and logging

Three switches live in `/etc/default/ufw`, and all three matter on a real server:

/etc/default/ufw:

```ini
IPV6=yes
DEFAULT_INPUT_POLICY="DROP"
DEFAULT_OUTPUT_POLICY="ACCEPT"
DEFAULT_FORWARD_POLICY="DROP"
```

`IPV6=yes` is the default on current releases and should stay that way. If your VDS has a routable IPv6 address, an IPv4-only ruleset protects half of the machine. Verify with `sudo ip6tables -L ufw6-user-input -n` that your rules exist in both families, or just read the `(v6)` rows in `ufw status`.

`DEFAULT_FORWARD_POLICY` governs routed traffic - containers, VPNs, anything where the box is a middle hop rather than a destination. `ufw default deny routed` sets it from the command line, and `ufw route allow proto tcp from any to 10.0.0.5 port 80` adds exceptions. After editing the file by hand, run `sudo ufw reload`.

Logging is off-by-default in the sense that `low` is quiet. `sudo ufw logging medium` records blocked packets and new connections to `/var/log/ufw.log` through syslog, which is enough to answer "is my rule the thing dropping this". It is also enough to fill a small disk on a busy public address, so turn it up while you are debugging and back down when you are done - `sudo ufw logging low`. [Logs worth keeping](https://www.renode.space/blog/logs-worth-keeping) has the general version of that trade.

#### Proving it works

Three checks, from the inside out. First, what is actually listening:

```bash
$ sudo ss -lntup
Netid State  Local Address:Port   Process
tcp   LISTEN 0.0.0.0:22           sshd
tcp   LISTEN 127.0.0.1:3000       node
udp   UNCONN 0.0.0.0:2456         valheim_server.x86
```

Anything bound to `0.0.0.0` or `[::]` is exposed to the network and is relying on the firewall. Anything on `127.0.0.1` is not reachable from outside no matter what the firewall says. This one command resolves more arguments than any other in this post.

Second, what the firewall thinks: `sudo ufw status verbose`, read from the top, remembering first-match-wins. `sudo ufw --dry-run allow 8080/tcp` prints the iptables lines a rule would generate without applying it, which is useful when you want to see where in the chain it would land.

Third, and the only one that counts, from somewhere else:

```bash
$ nmap -Pn -p 22,80,443,3000,8080 203.0.113.10
$ nmap -Pn -sU -p 2456,2457 203.0.113.10
$ nc -zv 203.0.113.10 25565
```

Scan from another machine, never from the server itself - loopback ignores the rules you are trying to test. If a port you expected to be closed answers, work backwards: is something published by Docker, is there a rule above your deny, is it open on IPv6 only. Doing this once after every change to the stack is a five-second habit that catches the database you exposed while debugging and forgot about.

#### When it goes wrong

**I enabled UFW and lost SSH.** Use the provider's console to get a shell, then `sudo ufw allow 22/tcp` (or your port) and `sudo ufw reload`. If you have no console, you have a reinstall. This is the failure this post exists to prevent.

**My allow rule does nothing.** Something above it matched first. `ufw status numbered`, then `ufw insert` the rule higher, or delete the broad rule that is shadowing it.

**The port is open but the service is unreachable.** The firewall is not your problem. Check `ss -lntup` for the process, check it is bound to `0.0.0.0` rather than `127.0.0.1`, and check the service log.

**The port is closed but I allowed it.** Check the protocol - a UDP game with a TCP rule is the classic. Check IPv6. Check whether there is a second firewall in front of the machine at the provider level.

**A container is reachable and I never allowed it.** Docker. See above.

**Rules vanished after a reboot.** `sudo systemctl is-enabled ufw` should say `enabled`. Hand-written `iptables` rules outside UFW do not persist on their own.

**`ufw reset` wiped everything.** That is what it does: it disables the firewall and moves the existing rule files to timestamped backups in `/etc/ufw/`. The old rules are still on disk if you need to read them back.

On a RE:NODE VDS all of this is yours to run, because the plan is a machine with full root access rather than a managed container - the firewall, the kernel and every listening port are under your control and nobody else's. That is the trade: on a managed game or app plan there is no firewall to configure at all, because each plan comes with its port allocations and you add or remove ports on the Network tab. On a VDS you get the flexibility and the responsibility together, and this post is part of the responsibility. The rest of the first day is in [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds).

#### FAQ

##### Should I change the SSH port to hide it?

It removes a large amount of automated log noise and stops nothing that is aiming at you specifically. Do it if you like quieter logs, but do it as well as keys, `limit` and fail2ban rather than instead of them. Remember to allow the new port before restarting `sshd`, not after.

##### Is UFW enough on its own?

For one server running a handful of services, a default-deny policy with a short allow list is most of the value available at this layer. It does not patch software, it does not stop credential guessing beyond rate limiting, and it cannot help with a flood that fills the link. Treat it as one of four or five things rather than the thing.

##### Why is my Docker container reachable when UFW says deny?

Because published container ports are forwarded rather than delivered to the host, and Docker's forwarding rules are evaluated before UFW's. Publish to `127.0.0.1` where possible, keep internal services off published ports entirely, and use the `DOCKER-USER` chain when you genuinely need a public container port restricted by address.

##### Do I need rules for outgoing traffic?

Rarely, on a single application server. Outbound filtering is valuable on a machine that should never initiate connections, and it is a real project on one that has to fetch packages and call APIs. If you go there, start by logging what leaves before you block anything.

##### What is the difference between `deny` and `reject`?

`deny` drops the packet with no reply, so the sender waits for a timeout. `reject` sends an ICMP unreachable or a TCP reset, so the sender fails immediately. Drop facing the internet, reject where fast, clear failures are more useful than silence.

##### Does UFW work with nftables?

Yes. On current Debian and Ubuntu the `iptables` commands UFW issues are handled by the `iptables-nft` translation layer and end up as nftables rules in the kernel. You can inspect the result with `sudo nft list ruleset`, though the output is considerably less readable than `ufw status`.

The RE:NODE VDS line runs on an Intel i9-9900K in Germany with NVMe on a ZFS pool and full root access, from 8 GB and two vCPU upwards, so the firewall, the kernel and every listening port are yours. Each one is prepared by hand rather than scripted, which is why it arrives within 24 hours rather than in a minute.

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

---

### VPS, VDS or dedicated server: what they mean

Published 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server

Three words, sold at three prices, and only one of them has a definition everyone agrees on. A dedicated server is a physical machine that nobody else is on. A VPS is a virtual machine on a shared physical machine. A VDS is a VPS whose seller wants you to know that the CPU share is not oversubscribed - which is sometimes a real technical difference and sometimes a rebranding. The useful question is never "which of the three do I want", it is "what exactly is guaranteed, and can I check it once the box is running". This post answers both, including the commands that tell you what you actually bought.

#### The three words, and which one is marketing

**VPS** stands for virtual private server. It means a virtual machine: a slice of a physical host, with its own operating system, its own IP address, and root access inside it. The word says nothing about how the slice is cut. A 1 GB VPS from one provider and a 1 GB VPS from another can differ by a factor of three in real performance, and both descriptions are accurate.

**VDS** stands for virtual dedicated server. The industry uses it to mean a VPS where the resources are not shared: your vCPU are pinned or reserved rather than fought over, your memory is not overcommitted, and your disk allocation is real. There is no standards body enforcing that, which is the whole problem. Some providers use VDS precisely; others use it as a synonym for VPS with a higher price. The word is a claim, not a specification, and a claim is worth exactly as much as what sits next to it: a named CPU model, a stated vCPU count, and an answer to "do you overcommit".

**Dedicated server** means the entire physical machine. One tenant, one set of hardware, no hypervisor between you and the CPU unless you install one yourself. This is the only one of the three with an unambiguous meaning, and the price reflects it.

The honest hierarchy is not VPS then VDS then dedicated. It is: how much of a physical machine is guaranteed to you, and how much of the surrounding work is yours. Those two questions are close to independent, which is why a badly chosen dedicated server can perform worse than a well-chosen VDS for the same money.

#### What the hypervisor actually does

Underneath the marketing there are genuinely different technologies, and the difference shows up in what you can do.

**Full virtualisation (KVM, VMware, Hyper-V, Xen HVM).** The guest gets an emulated machine and runs its own kernel. You can load kernel modules, run Docker, set your own `sysctl` values, use any distribution, mount a swap file, and run a different kernel version from the host. This is what "a VPS" almost always means today, and it is what you want unless you have a specific reason otherwise.

**Container virtualisation (OpenVZ, LXC, Virtuozzo).** The guest shares the host's kernel. It is cheaper to run and denser, so it is cheaper to sell, but you cannot change kernel parameters, some `sysctl` settings are read-only, swap behaves oddly, and certain workloads - anything wanting kernel modules, some VPN configurations, occasionally Docker - do not work or work badly. Memory accounting is also looser, which is how a container VPS can be "out of memory" while `free -m` says otherwise. OpenVZ has faded but it has not vanished from the cheap end of the market.

**Application containers (Docker, and therefore Pterodactyl-based game hosting).** A different thing again, and not a VPS at all. One process tree per container, memory and CPU capped by the kernel's control groups, no kernel of your own and no root on the host. This is what a managed game or app plan is, and it is compared properly in [VDS or a game panel](https://www.renode.space/blog/choosing-between-vds-and-a-game-panel).

**Bare metal.** No hypervisor. Every cycle and every byte of memory bandwidth is yours, including the ones you are not using.

A single command tells you which of these you are on:

```bash
$ systemd-detect-virt
kvm

$ systemd-detect-virt --container
none
```

`kvm`, `vmware`, `microsoft` or `xen` means full virtualisation. `lxc`, `openvz` or `docker` means you are sharing a kernel. `none` means bare metal, or a hypervisor that is hiding well.

#### "Dedicated resources" is three separate promises

When a page says dedicated resources it may mean any of three things, and they are worth separating because only one of them is usually what people care about.

**Dedicated memory.** Your RAM allocation is backed by real memory and not overcommitted. This is common, cheap to provide and rarely the thing that goes wrong.

**Dedicated CPU.** Your vCPU are not shared with other tenants. On a shared VPS, eight tenants with two vCPU each can sit on a host with eight threads; when everyone is busy, everyone waits. On a properly dedicated CPU plan the mapping is closer to one vCPU per hardware thread and nobody else is scheduled on yours. This is the promise the letters VDS are meant to carry, and it is the one that most affects a game server, because a game server is one thread's worth of work that has to finish on time.

**Dedicated disk and network.** Far rarer than it sounds. NVMe drives are almost always shared through the same controller by every guest on the box, and the uplink is shared by definition. What good providers actually offer here is a queue-depth or IOPS limit per guest so that one noisy tenant cannot starve the rest.

The measurable consequence of a shared CPU is **steal time**: the percentage of time your vCPU was ready to run and the hypervisor gave the physical core to somebody else. It appears as the `st` column in `vmstat` and the `%st` figure in `top`:

```bash
$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 612344  84120 917640    0    0     3    14  201  388  9  2 88  0  1
```

Under 1% is normal and unavoidable. A sustained 5% or more on a plan sold as dedicated CPU means it is not. That is a number you can screenshot and put in a ticket, which is more than can be said for "it feels slow".

[Shared CPU and noisy neighbours](https://www.renode.space/blog/shared-cpu-and-noisy-neighbours) goes further into what a share guarantees and what it does not.

#### The physical machine, and what it really costs

A dedicated server removes the hypervisor and the neighbours. What it does not remove is any of the work.

What you genuinely gain:

- **All of the CPU, including the cache.** On a virtualised host, neighbours evict your data from the shared L3 cache whether or not they are using CPU time you were promised. This is invisible in every benchmark that measures throughput and very visible in workloads sensitive to latency.
- **Predictability.** The 99th percentile of anything is better on a machine nobody else touches. For a game server, the 99th percentile is what players notice.
- **Hardware choices.** Specific drives, specific RAID, more memory than any VPS tier offers, sometimes a GPU.
- **No overcommit, ever.** Not because the provider promised, but because there is nobody to overcommit to.

What it costs beyond the monthly figure:

- **Provisioning time.** Bare metal is racked, imaged and handed over. That is hours to days, not minutes, and the same goes for a replacement when a drive dies.
- **The whole sysadmin job.** Same as a VDS, except that now the hardware is also yours to think about. RAID health, firmware, IPMI, a BIOS setting that reverted after a power cut.
- **Idle capacity you paid for.** A dedicated machine is sized for your peak. Everything above your average is money spent on being ready.
- **A single failure domain.** One machine is one machine. A VM can often be migrated to another host in minutes. A dead motherboard cannot.

The rule that holds up: take bare metal when you are actually going to use most of it, or when the 99th percentile is the product. Below that, a properly specified VDS is the same hardware with someone else responsible for the parts that are not your job.

RE:NODE's own line on [the dedicated servers page](https://www.renode.space/dedicated-servers) is a VDS rather than bare metal, and the page says which machine it sits on: an Intel i9-9900K, with NVMe throughout on a ZFS pool, in Germany. That matters more than the category label. An i9-9900K is an eight-core, sixteen-thread part with a high single-core clock, which is exactly the shape of CPU a game server or a small database wants, and it is why the tiers stop at six vCPU: six of sixteen threads is a share that can be kept honest. Unlike the managed plans, which are created when payment clears and are usually running inside a minute, a VDS is prepared by hand and delivered within 24 hours.

#### Reading a spec sheet without being fooled

The same six lines decide almost every comparison, and most listings answer two of them.

| What the page says | What to actually ask |
| --- | --- |
| "4 vCPU" | Four threads of what CPU, and are they shared? |
| "Dedicated CPU" | Pinned cores, or a scheduler weight? |
| "NVMe SSD" | IOPS or queue limit per guest, and is it RAID? |
| "8 GB RAM" | Is memory overcommitted on the host? |
| "Unmetered" | What is the port speed, and what is the fair-use policy? |
| "DDoS protected" | Always on or triggered, and what does it drop? |

A concrete example of why the first row matters: four vCPU on a 2018 eight-core desktop part with a 5.0 GHz boost will beat four vCPU on a sixty-four-core server part clocked at 2.2 GHz for almost any game server, because the game server needs one thread to finish a tick on time and does not care about the other sixty. The same two machines reverse completely for a build farm or a web server handling many independent requests. A vCPU count with no model name attached cannot be compared with anything.

The second row is worth one follow-up question in a pre-sales ticket, because the answers differ: pinned cores means specific hardware threads are yours; a scheduler weight means you get a larger slice when there is contention, which is a different and weaker promise.

#### Which one fits which workload

| Workload | The right answer | Why |
| --- | --- | --- |
| One or two game servers | Managed plan | You need a fraction of a machine and none of the work |
| Six or more small services | VDS | Memory pools across them, and one OS serves all |
| Software nobody sells as a product | VDS | Root and a kernel of your own are the entire point |
| A busy database with real IO | VDS or bare metal | IO contention is what you are escaping |
| Your own game hosting panel | VDS, 16 GB and up | Panel, Wings, Docker and a database before any players |
| Sustained high CPU, all day | Bare metal | On shared infrastructure you are the noisy neighbour |
| Latency-critical at the 99th percentile | Bare metal | No hypervisor, no cache eviction by strangers |
| Anything you will not maintain | Managed plan | The cheapest hour is the one you do not spend |

The pattern in that table is that virtualisation is almost never the deciding factor. What decides it is how many separate things you are running, how spiky they are, and whether you want the operating system to be your responsibility. [How much a game server costs](https://www.renode.space/blog/how-much-does-a-game-server-cost) approaches the same decision from the money side, and [several game servers on one VDS](https://www.renode.space/blog/multiple-game-servers-on-one-vds) is the practical version of the second row.

> [!NOTE]
> A VDS is not faster at running one game server than a managed container with the same allocation. It is the same hardware with a different boundary drawn around it. What a VDS buys is the ability to run things nobody sells, and the ability to pool memory between several of them. Speed is not on the list.

#### Checking what you actually got

Do this in the first ten minutes, before you install anything, and keep the output. It is the difference between a ticket that gets resolved and a conversation about feelings.

```bash
$ systemd-detect-virt            # kvm, lxc, openvz, docker or none
$ lscpu | head -20               # model name, sockets, cores, MHz
$ nproc                          # threads visible to you
$ free -m                        # real memory, and whether swap exists
$ df -h /                        # disk actually available
$ uname -r                       # your kernel, or the host's
```

`lscpu` is the important one. It prints the model name, which turns "4 vCPU" into something comparable, and the `Hypervisor vendor` line confirms what `systemd-detect-virt` said. If the model name is blank or generic on a plan sold as dedicated CPU, ask why.

Then measure rather than trust. Single-core speed is the number that predicts game server behaviour, and `sysbench` gives a crude but comparable figure in ten seconds:

```bash
$ apt install sysbench fio
$ sysbench cpu --threads=1 --time=10 run | grep "events per second"
$ sysbench cpu --threads=$(nproc) --time=10 run | grep "events per second"
```

Run the single-threaded test three times at different hours. If the results vary by more than about 10%, you are sharing with somebody busy. For disk, a small random-write test is the one that matches what a world save or a database commit actually does:

```bash
$ fio --name=randwrite --ioengine=libaio --direct=1 --rw=randwrite \
      --bs=4k --size=1G --numjobs=1 --runtime=30 --group_reporting
```

Look at the IOPS figure and at the 99th percentile latency, not at the average. [What NVMe actually changes](https://www.renode.space/blog/what-nvme-actually-changes) explains which operations that number governs and which it never touches.

Finally, watch steal time for a week with `vmstat 60` piped to a file, or just glance at `top` at peak hours. On a container-virtualised plan steal time will read zero regardless, because throttling is accounted differently; check the control group instead:

```bash
$ cat /sys/fs/cgroup/cpu.stat
usage_usec 184203311
nr_periods 291844
nr_throttled 0
throttled_usec 0
```

A climbing `nr_throttled` means you are hitting your own CPU limit, which is a sizing problem rather than a neighbour problem, and the fix is a bigger share or less work. [CPU or RAM: which one is holding your server back](https://www.renode.space/blog/cpu-vs-ram-for-game-servers) is the method for deciding which.

Once you are satisfied with what you bought, spend the next half hour on [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds): a non-root user, SSH keys, a default-deny firewall and unattended upgrades. A brand new box with password authentication is found by scanners within hours of its address going live, and that timeline is not an exaggeration.

#### FAQ

##### Is a VDS just a VPS with a different name?

Sometimes. The intended distinction is that a VDS does not oversubscribe CPU or memory, so your share is genuinely yours. Nothing enforces the word, so treat it as a claim to verify: ask for the CPU model, ask whether cores are pinned, then measure steal time yourself for a week.

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

Almost never. A single game server uses one thread's worth of simulation and a few gigabytes of memory, which is a small fraction of any modern machine. Bare metal is worth it when you are running many servers at once, when you need every cycle of a fast core at the 99th percentile, or when you are reselling.

##### How many vCPU do I need?

Fewer than you think, and faster ones. Most game servers, most small web applications and most databases under real load are limited by how quickly one thread finishes its work. Two vCPU with a high clock beats eight slow ones for that shape of workload. Add cores when you are running several independent things, not to make one thing faster.

##### Can I run Docker on any VPS?

On KVM, VMware, Hyper-V or Xen, yes. On container virtualisation such as OpenVZ or LXC it ranges from awkward to impossible, because you do not control the kernel. Run `systemd-detect-virt` before buying anything where Docker is part of the plan, and prefer full virtualisation if you are unsure.

##### What happens when a VDS runs out of memory?

The kernel's OOM killer picks a process and terminates it, usually the largest, which is usually the thing you cared about. There is no warning and the evidence is in `dmesg`. Managed containers behave differently: the container is stopped at its limit and restarted clean rather than left to swap.

##### Is the hardware different between a VPS and a VDS?

Usually not. The same machines often serve both, and the difference is how many guests are placed on them and whether CPU is pinned. That is precisely why the CPU model and the overcommit policy are worth more than the product name on the page.

The RE:NODE VDS line runs on an Intel i9-9900K in Germany, with NVMe throughout on a ZFS pool, full root access, and tiers from 8 GB with two vCPU up to 24 GB with six. It is prepared by hand rather than scripted, so it arrives within 24 hours rather than in a minute.

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

---

### VDS or a game panel: which do you need

Published 2026-07-14, updated 2026-09-21 by RE:NODE Team. https://www.renode.space/blog/choosing-between-vds-and-a-game-panel

The short version: if you want one to five game servers and you do not enjoy Linux administration, buy the managed plan. If you need software nobody sells as a product, or you are packing eight small services onto one box, buy the VDS. The interesting cases sit between those two, and they are usually decided by temperament rather than by numbers. This post is an attempt to decide them with numbers instead - what each one actually gives you, what the operating system costs you in memory before you run anything, and what the sysadmin job attached to root access takes in hours per month.

People ask for a VDS because they want control. What they get is control plus a second unpaid job: the firewall, the updates, the backups, the certificate that expires on a Sunday, and the restart at three in the morning that nobody else is going to do. Sometimes that trade is obviously right. Often it is bought by somebody who wanted to run a Minecraft server and ends up reading `sshd_config` documentation instead.

#### What you are actually renting

A managed game or app plan is a container on a shared machine. On a Pterodactyl-based host that means one Docker container per server, with memory, disk and CPU capped by the kernel's control groups, and a web panel as the only interface. You get a console, a file manager, SFTP, scheduled tasks and backups. You do not get a shell on the host, you do not get root, and you cannot install a package that is not part of the image your server runs from.

A VDS is a virtual machine. It has its own kernel, its own distribution, its own users, and you are `root`. Everything above the hypervisor is yours, which includes every mistake.

| | Managed plan | VDS |
| --- | --- | --- |
| Interface | Panel: console, files, schedules | SSH, and whatever you install |
| Root | No | Yes |
| Arbitrary software | No | Yes |
| Operating system updates | Not your problem | Yours, weekly |
| Firewall | Ports allocated per server | You write the rules |
| Backups | Slots on every plan, one-button restore | You build it |
| Delivery | Created when payment clears, usually inside a minute | Prepared by hand, within 24 hours |
| Resource isolation | One container per server, hard CPU cap | One VM, you divide it yourself |
| Failure mode | Container restarts clean | You get paged |

The dividing line is not power. A container on good hardware will run a Minecraft server exactly as fast as the same allocation on a VM sitting beside it. The dividing line is responsibility, and responsibility has a price in hours that never appears next to the monthly figure. If the terminology is what is bothering you, [VPS, VDS or dedicated server](https://www.renode.space/blog/vps-vs-vds-vs-dedicated-server) separates the three properly, and [what a Pterodactyl panel gives you](https://www.renode.space/blog/pterodactyl-panel-explained) covers the managed side in detail.

#### The overhead nobody prices in

This is the part that decides most real comparisons, and it is almost never in the table on a pricing page. A VDS runs a whole operating system before it runs anything you care about.

Measure it yourself on a fresh box with `free -m` after a reboot, but the usual shape is:

| Component | Typical resident memory |
| --- | --- |
| Minimal Debian or Ubuntu server, idle | 150-350 MB |
| Docker daemon, no containers running | 80-150 MB |
| nginx plus PHP-FPM for a small site | 150-400 MB |
| A Pterodactyl panel installed on the same box | 700 MB - 1.2 GB with its database and Redis |
| Monitoring agent, fail2ban, journald | 50-150 MB |

So an 8 GB VDS is not 8 GB of game server. It is roughly 7.5 GB if you run services directly, and closer to 6 GB if you install a panel on it so that your friends can restart things without an SSH key. Compare that against buying the same memory as managed plans, where the overhead lives on the host and you are billed for what your process gets.

The arithmetic cuts both ways and it depends entirely on the shape of your workload:

- **One big thing.** A single 10 GB Minecraft server is cheaper and less work as a managed plan. You pay for exactly the memory the JVM gets, the install is done, and you have not spent a weekend on it.
- **Many small things.** Five Discord bots, two small game servers, a Postgres instance and a staging copy of your website is where a VDS pulls ahead, because memory pools. A bot that idles at 90 MB borrows from the bot that spikes to 400 MB. Buy those as eight separate plans and each one carries its own ceiling, paid for whether it is used or not.
- **Bursty and unpredictable.** A VDS lets you overcommit and find out. A panel plan will stop the container at the limit and restart it clean, which is safer and less forgiving.

The single most useful question is: how many separate things am I running, and how spiky are they? Below about four, the panel almost always wins on total cost once you value your time above zero. Above about six, the VDS starts to win on money even before you count the pooling.

#### Which one is obviously right

There are cases where this is not a judgement call.

Take the managed plan when:

- You want between one and five servers and no opinions about the distribution.
- You want the install, the start command, the Java version and the updates handled.
- You want to hand somebody console access without handing them the machine. Roles, teams and time-boxed grants exist for exactly this, and [subusers and least privilege](https://www.renode.space/blog/subusers-and-least-privilege) walks through setting them up.
- You would rather restore a backup with a button than remember how you set up the cron job that was supposed to be making them.
- Your reaction to the phrase "check the `DOCKER-USER` chain" is to close the tab.

Take the VDS when:

- You need software that is not sold as a hosting product: a Matrix homeserver, a mail relay, a custom binary, a specific kernel module, an obscure distribution.
- You are running enough services that pooling memory is worth real money.
- You want your own Docker or Compose stack, with volumes and networks you define. [Docker on a VDS](https://www.renode.space/blog/docker-on-a-vds-getting-started) and [Compose for small stacks](https://www.renode.space/blog/docker-compose-for-small-stacks) are the starting points.
- You already administer Linux daily and the panel would be in your way.
- You need one machine with a single address running several game servers on ports you choose, which is its own topic in [several game servers on one VDS](https://www.renode.space/blog/multiple-game-servers-on-one-vds).

What should not decide it: the belief that a VDS is faster. CPU on a managed plan is a hard throttle to the share you bought, so a server sitting at 100% is slow rather than broken, and it is never suspended for it. A VDS with two vCPU is throttled at two vCPU in the same way. The difference is that on a VDS nothing stops one of your own services from eating the whole allocation and starving the others, which is a problem you have now bought the right to solve. [Shared CPU and noisy neighbours](https://www.renode.space/blog/shared-cpu-and-noisy-neighbours) covers what a share actually guarantees.

#### The first hour on a VDS, in commands

If you take the VDS, this is the minimum before you install anything. None of it is optional, and all of it is on you. There is a longer version in [the first hour on a new VDS](https://www.renode.space/blog/first-hour-on-a-new-vds).

1. Update everything and reboot if the kernel changed.
2. Make a normal user with sudo, and stop using root interactively.
3. Put an SSH key on it and turn passwords off.
4. Close every port you are not deliberately using.
5. Turn on automatic security updates so that the box does not rot.

```bash
$ apt update && apt full-upgrade -y
$ adduser deploy && usermod -aG sudo deploy
$ ssh-keygen -t ed25519 -C "laptop"          # run this on your machine
$ ssh-copy-id deploy@203.0.113.10
```

Then harden the daemon. Put the changes in a drop-in file rather than editing the main config, so that a package upgrade does not silently revert them:

/etc/ssh/sshd_config.d/99-hardening.conf:

```ini
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
```

Test the new settings in a second terminal before you close the first one. `sshd -t` checks the syntax; a locked-out VDS needs a console session from the provider to fix. [SSH keys and hardening](https://www.renode.space/blog/ssh-keys-and-hardening) has the rest, including agents and jump hosts.

The firewall is next. Default deny, then open exactly what you need:

```bash
$ ufw default deny incoming
$ ufw default allow outgoing
$ ufw allow 22/tcp
$ ufw allow 25565/tcp        # Minecraft
$ ufw allow 2456:2457/udp    # Valheim game and query
$ ufw enable
$ ufw status numbered
```

> [!WARNING]
> Docker publishes container ports by writing its own iptables rules, and those rules are consulted before ufw's. A container started with `-p 5432:5432` is reachable from the internet even when `ufw status` shows the port as denied. Bind it to localhost with `-p 127.0.0.1:5432:5432`, or filter in the `DOCKER-USER` chain. This catches people with an exposed database roughly once a week somewhere.

Finish with unattended upgrades and a ban daemon:

```bash
$ apt install unattended-upgrades fail2ban
$ dpkg-reconfigure -plow unattended-upgrades
$ systemctl enable --now fail2ban
$ fail2ban-client status sshd
```

That is about forty minutes the first time and fifteen once you have done it twice. It is also the cheap part.

#### Keeping it alive is the actual job

Nothing on a VDS restarts itself unless you arrange it. The tool is systemd, and the unit file is where most of the real differences between "it runs" and "it survives" live:

/etc/systemd/system/valheim.service:

```ini
[Unit]
Description=Valheim dedicated server
After=network-online.target

[Service]
User=valheim
WorkingDirectory=/home/valheim/server
ExecStart=/home/valheim/server/valheim_server.x86_64 -nographics -batchmode \
  -name "Longship Crew" -port 2456 -world "Midgard" -password "herring-barrel-42"
Restart=on-failure
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=90

[Install]
WantedBy=multi-user.target
```

`KillSignal=SIGINT` is the line that matters and the one people leave out. Many game servers save on a clean interrupt and lose everything since the last autosave on a `SIGKILL`, so a default unit file that sends `SIGTERM` and gives up after 90 seconds can quietly cost you an evening of progress every time the box reboots. [Systemd services for your apps](https://www.renode.space/blog/systemd-services-for-your-apps) goes through the rest of the directives.

Backups are the other thing that does not happen by itself. A snapshot taken by the provider protects against the disk dying; it does not protect against you deleting a world at midnight. Something like restic, pointed at storage on a different machine, costs ten minutes to set up:

```bash
$ restic -r sftp:backup@198.51.100.5:/srv/restic init
$ restic -r sftp:backup@198.51.100.5:/srv/restic backup /home/valheim/server/worlds_local
$ restic -r sftp:backup@198.51.100.5:/srv/restic forget \
    --keep-daily 7 --keep-weekly 4 --prune
```

Put that in a systemd timer or a cron entry, then restore one, to somewhere harmless, before you need to. A backup nobody has restored is a hypothesis, and [testing a restore before you need it](https://www.renode.space/blog/testing-a-restore-before-you-need-it) is the shortest post on this site worth reading twice.

On a managed plan, that entire section is a Backups tab with slots included on every plan, on demand or on a schedule, restored with a button and stored off the machine they protect. The panel side also has a Schedules tab with cron expressions and ordered tasks, so a nightly backup plus a weekly restart is about two minutes of clicking rather than two unit files.

#### Running a panel on your own VDS

The common ambition is to have both: root access and a nice interface for the people who share the server. This is possible and a lot of people do it, but be clear about what you are signing up for.

```diagram
title: The same game server, owned two different ways
you = You
friends = Your friends
plan = Managed plan | panel, backups, SFTP
vds = Your VDS | root, ufw, updates
wings = Wings + Docker | you install and patch it
game = Game server
you -> plan : browser
friends -> plan : subuser account
you -> vds : ssh
vds -> wings
wings -> game
plan -> game
```

Pterodactyl is two pieces. The panel is a Laravel application that needs a web server, PHP with the extensions it lists, a MySQL-compatible database, Redis and Composer. Wings is a Go daemon that needs Docker and talks to the panel over its own port, with SFTP served separately. Upstream defaults put the Wings API on `8080` and its SFTP listener on `2022`, with the panel itself on `80` and `443`.

That is at least five services to keep patched, plus certificates for the panel hostname, plus the Docker images for every game you run. On a 16 GB VDS that is fine. On the 8 GB tier it is a meaningful slice of your memory spent on infrastructure rather than on players, and every one of those components is a thing that can break at an inconvenient hour.

A reasonable rule: install a panel on your own machine when you are hosting for other people and the panel is part of the product. Do not install one so that three friends can press Restart. Give them a subuser account on a managed plan instead and keep your weekend.

#### Security is where the difference is real

On a managed plan the security model is already built, and it is the same on every plan: two-factor with authenticator codes and single-use recovery codes, granular roles so somebody can have console but not billing, per-server activity logs, SFTP credentials issued per server rather than per account, and API keys that can be restricted by address. Platform backups run nightly, are copied to separate hardware, verified and pruned. If a container gets into trouble it is stopped and restarted clean.

On a VDS every one of those is a thing you build or go without. Worth being blunt about what actually goes wrong on small self-managed boxes, in rough order of frequency:

- A password-authenticated SSH account with a weak password, found by a scanner within hours of the address going live.
- A database or admin interface bound to `0.0.0.0` and published through Docker past the firewall.
- An application dependency with a known hole, on a box where nothing has been updated since the day it was ordered.
- Credentials committed to a public repository, then used from somewhere else entirely.

Almost none of it is a kernel exploit. Check what is listening with `ss -tulpen` on the day you finish setting up and again once a month, and keep the answer short. [Firewall rules that matter](https://www.renode.space/blog/firewall-rules-that-matter) is the practical version of that habit.

#### The cost in hours, not just dollars

Price the hours at whatever your hour is worth and then compare. These are typical, not worst cases, and they assume nothing goes badly wrong.

| Task | Managed plan | VDS |
| --- | --- | --- |
| Getting a game server running | 1 minute after payment | 1-3 hours the first time |
| Operating system updates | None | 15-30 minutes a month |
| Setting up backups | Toggle a schedule | 30-60 minutes, once |
| Restoring a backup | One button | 10-30 minutes, if you practised |
| Adding a second game server | Order it | 20-60 minutes |
| Giving a friend restart access | Invite a subuser | An SSH account, or a script, or a panel |
| A crash at 3am | Watched for, restarted, ticket opened | Yours |

That last row is worth expanding, because it is the one that separates the two properly. On a managed plan a watcher polls every couple of minutes for a server that went offline or whose uptime went backwards, and restarts you asked for are not counted against you. Three unexpected restarts in an hour puts a warning on the server page and opens a ticket automatically. On a VDS the equivalent is a `Restart=on-failure` line and whatever monitoring you built, and the first you hear about a crash loop is usually a message from a player. [Monitoring that tells you something](https://www.renode.space/blog/monitoring-that-tells-you-something) is worth reading before you build that yourself.

Money, using the current ladders as a sanity check: the VDS line starts at 8 GB, 128 GB of disk and two vCPU on an Intel i9-9900K, and steps up to 16 GB with four vCPU and 24 GB with six. A single managed Minecraft plan with 4 GB and 1.5 vCPU is a fraction of that. Two or three separate managed servers land in the same range as the entry VDS with less total memory but none of the work. Six or seven separate small services do not, and that is the crossover.

#### FAQ

##### Is a VDS faster than a managed game server?

Not inherently. Both are a share of the same kind of hardware with a hard CPU cap, and a Minecraft server given 1.5 vCPU behaves the same either way. A VDS can feel faster because you can tune the whole machine, and slower because you are also responsible for the tuning.

##### Can I run a game hosting panel on my VDS?

Yes. Pterodactyl's panel and Wings will install on any recent Debian or Ubuntu with Docker. Budget around 1 GB of memory and several services worth of patching for the privilege, and only do it if a panel is part of what you are offering other people.

##### Do I get root or SSH on a managed plan?

No. You get SFTP with credentials issued per server, an in-browser file manager with a syntax-highlighting editor, and an unfiltered console with command history. That covers configuration, uploads and log reading, which is most of what people actually want root for.

##### How much Linux do I need to know before taking a VDS?

Enough to be comfortable with the package manager, `systemctl`, `journalctl`, the firewall and file permissions without looking each one up. If that list reads as homework rather than as a Tuesday, the panel is the right answer for now.

##### What happens when a VDS runs out of memory?

The kernel's OOM killer picks a process and terminates it, usually the largest one, which is usually the thing you cared about. Managed containers are stopped at the limit and restarted clean instead of being left to swap. [Swap and the OOM killer](https://www.renode.space/blog/linux-swap-and-the-oom-killer) explains how to read the evidence afterwards.

##### Can I move from one to the other later?

Yes, and the data usually moves cleanly because a world or an application is just files. Copy them off with SFTP, copy them back on the other side, change the address your players use. [Moving a server without losing players](https://www.renode.space/blog/moving-a-server-without-losing-players) covers doing it without a day of confusion.

Full root access on real hardware in Germany, prepared by hand and delivered inside a day, on NVMe throughout. If you would rather skip the sysadmin job, every managed plan gets the same panel: console, file manager, SFTP, schedules and backup slots, with the server created about a minute after payment clears.

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

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