LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The Complete VPS Setup Guide: Security, Performance, and Quality of Life

linuxvpssecuritysshsysadmindevopsserverfirewallnginxmonitoring
Contents

A freshly provisioned VPS is a blank canvas: a full operating system with root access and a public IP address. That freedom is exhilarating and dangerous in equal measure. Within minutes of boot, automated scanners will find your server and begin probing every port. Without intervention, a default Ubuntu or Debian install will have SSH open to the entire internet, root login enabled, and password authentication accepting guesses from anywhere.

This guide walks through the complete lifecycle of a VPS — choosing a vendor, hardening first boot, mastering SSH, configuring firewalls, setting up monitoring and backups, and adding quality-of-life improvements that make administration feel smooth rather than painful.


Part 1: Choosing a VPS Vendor

The VPS market has consolidated around a handful of serious players plus a long tail of value providers. Here is an honest assessment of the major options in 2026.

Vendor Comparison Table

Vendor Entry Price Network Control Panel Standout Feature Best For
Hetzner €3.29/mo (CX22) 1Gbps, generous traffic Cloud Console + API Price/performance ratio EU workloads, cost-sensitive projects
DigitalOcean $4/mo (Droplet) 1Gbps Best-in-class UI + API Developer experience, tutorials Indie developers, small teams
Linode / Akamai $5/mo 1Gbps Good UI + CLI Stability, mature platform Businesses needing reliability
Vultr $2.50/mo 1–10Gbps Clean UI + API 32 global locations Low-latency, geographically diverse
OVHcloud €3.50/mo 1Gbps, unmetered cPanel optional Anti-DDoS, scale EU compliance, high-traffic sites
UpCloud $5/mo MaxIOPS storage Good UI + API I/O performance Databases, high-write workloads
Contabo €4/mo 200Mbps Basic Raw specs per euro Dev/staging where overselling is OK
Hostinger $4.99/mo (KVM 1) 100–400Mbps hPanel (custom) Beginner UX, 1-click apps First VPS, WordPress, budget web hosting
AWS Lightsail $3.50/mo 1Gbps Simple UI AWS ecosystem access Existing AWS shops
Scaleway €4.39/mo 400Mbps–1Gbps Clean UI + API EU/GDPR focus, ARM options French/EU regulated workloads

Deep Dive: Top Vendors

Hetzner is a German company with datacenters in Nuremberg, Falkenstein, Helsinki, and Ashburn (US). Their price/performance ratio is unmatched: the CX32 (4 vCPU, 8GB RAM) costs €5.49/month at the time of writing — comparable hardware costs $24–48/month elsewhere.

Strengths:

  • Genuinely excellent hardware — dedicated CPU options (CCX series) give true unshared cores
  • Hetzner Object Storage and Volumes (block storage) at competitive rates
  • hcloud CLI is well-designed and scriptable
  • Private networking between servers in the same project is free
  • Floating IPs for zero-downtime migrations

Weaknesses:

  • Fewer non-EU datacenter locations than US-centric competitors
  • Support response time is slower than DigitalOcean or Linode
  • Limited marketplace compared to DigitalOcean 1-click apps

Best for: European workloads, cost-sensitive personal projects, anyone running multiple servers where the savings compound quickly.

DigitalOcean (Best Developer Experience)

DigitalOcean built its reputation on making Linux servers accessible to developers who weren’t sysadmins. Their documentation is exceptional — nearly every common setup task has a well-maintained tutorial. The Droplets product is rock-solid.

Strengths:

  • Best-in-class control panel and API
  • Excellent community tutorials (tutorials.digitalocean.com)
  • Managed databases, App Platform, Kubernetes (DOKS) — easy upgrade path
  • Droplet console via browser when you’re locked out of SSH
  • One-click backups and snapshots

Weaknesses:

  • Premium pricing compared to Hetzner or Vultr — you’re paying for DX
  • The cheapest Droplet ($4) has only 512MB RAM — not useful in practice; budget $6–12

Best for: Solo developers, small teams, anyone who values good documentation and an easy upgrade path to managed services.

Linode / Akamai Cloud (Most Stable)

Linode has been running VPS servers since 2003 and was acquired by Akamai in 2022. It doesn’t have the slick UX of DigitalOcean or the pricing of Hetzner, but it has an excellent reliability record and responsive support.

Strengths:

  • Longest uptime reputation in the industry
  • Excellent support (even on free plans)
  • Generous network: 1Gbps with good monthly transfer allowances
  • Strong in North America, UK, and Southeast Asia

Weaknesses:

  • UI feels dated compared to DigitalOcean
  • Pricing is middle-of-road — not cheap, not premium

Best for: Businesses that need reliability over cost, teams where support responsiveness matters.

Vultr (Most Global)

Vultr has 32+ datacenter locations including cities that other providers ignore (Seoul, Tel Aviv, São Paulo, Johannesburg). For applications where geographic proximity to users matters, Vultr’s footprint is unmatched in its price range.

Strengths:

  • Widest global coverage at affordable prices
  • 10Gbps networking at higher tiers
  • Bare metal option for when you need a whole machine
  • High-frequency compute (NVMe, faster CPUs) at reasonable premium

Weaknesses:

  • Support quality is inconsistent
  • Occasional performance variability (shared CPU tiers)

Best for: Geographically distributed deployments, CDN edge nodes, applications with latency-sensitive users in unusual regions.

Contabo (Raw Value, Caveats Apply)

Contabo offers specs that seem impossible for the price — 4 vCPU, 8GB RAM for €4/month. They achieve this through aggressive overselling. Network is often 200Mbps rather than 1Gbps. Performance varies significantly depending on neighbor workloads.

Use Contabo for: Development environments, non-production testing, anything where occasional slow periods are acceptable.

Avoid Contabo for: Production services, databases, anything with SLA requirements.

Hostinger (Best for Beginners and Budget Web Hosting)

Hostinger started as a shared hosting provider but has grown into a serious VPS player — particularly for developers and small site owners who want more control without a steep learning curve. Their KVM-based VPS plans start at $4.99/month and include a genuinely polished control panel (hPanel) that abstracts away much of the server administration complexity that trips up first-timers.

Strengths:

  • hPanel is one of the most beginner-friendly VPS control panels available — OS reinstalls, SSH key management, firewall toggles, and console access are all one click away
  • 1-click installation for popular stacks: WordPress, LAMP, LEMP, Node.js, Django, and more
  • KVM virtualization (not OpenVZ) — proper resource isolation, your own kernel, supports Docker
  • Datacenters across the US, EU (Netherlands, UK, Lithuania), Asia (Singapore, India), and Brazil — solid geographic coverage
  • Weekly automated backups included on most plans; daily backups available as an add-on
  • Competitive pricing: the KVM 2 plan (2 vCPU, 8GB RAM, 100GB NVMe) lands around $7.99/month on longer billing cycles

Weaknesses:

  • Network bandwidth is throttled on entry plans (100Mbps on KVM 1 vs 1Gbps on competitors at similar prices)
  • Support quality is inconsistent — live chat is available 24/7 but complex issues can take multiple rounds to resolve
  • Less developer-centric than DigitalOcean: no managed databases, no Kubernetes offering, no mature CLI/API ecosystem
  • Promotional pricing requires 12–48 month commitments; month-to-month rates are significantly higher — read the renewal pricing carefully before buying

Best for: First-time VPS users, WordPress and small web hosting workloads, anyone who wants a GUI-first experience to learn server administration before going fully terminal-native.

What to Check Before Committing

  1. Datacenter location — latency to your users matters; pick the closest region
  2. IPv6 support — all serious providers include this; verify before buying
  3. Unmetered vs. metered traffic — Hetzner and OVH include generous traffic; AWS charges per GB
  4. Snapshot/backup pricing — varies wildly; check before enabling
  5. SLA — most budget providers offer 99.9% (8.7hrs downtime/year); enterprise needs 99.99%
  6. Exit costs — can you export your data and leave easily?

Part 2: The First 30 Minutes — Security Baseline

The moment a VPS boots, automated scanners find it. Shodan indexes it. Bots start hammering SSH port 22. The following sequence should be completed before you do anything else.

Step 0: Update Everything

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Connect as root for the first time
ssh root@YOUR_SERVER_IP

# Update package lists and upgrade all packages
apt update && apt full-upgrade -y

# Install essential tools
apt install -y curl wget git htop vim ufw fail2ban unattended-upgrades \
  software-properties-common ca-certificates gnupg

# Reboot if kernel was updated
reboot

Step 1: Create a Non-Root Admin User

Never administer a server as root. Create a regular user, grant sudo, and set up SSH keys for that user.

1
2
3
4
5
6
7
8
9
# Create a new user
adduser deploy

# Add to the sudo group
usermod -aG sudo deploy

# Switch to the new user's home and create .ssh directory
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

On your local machine, copy your public key to the server:

1
2
3
4
5
6
7
# Option 1: ssh-copy-id (easiest)
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@YOUR_SERVER_IP

# Option 2: Manual paste
cat ~/.ssh/id_ed25519.pub
# Copy the output, then on the server:
# echo "your-public-key-content" >> /home/deploy/.ssh/authorized_keys

Back on the server:

1
2
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

Test the connection from your local machine before proceeding:

1
2
ssh deploy@YOUR_SERVER_IP
# If this works, proceed. If not, debug before continuing.

Step 2: Harden SSH

Edit /etc/ssh/sshd_config. Make a backup first:

1
2
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
vim /etc/ssh/sshd_config

Set the following values (add or modify as needed):

# Disable root login entirely
PermitRootLogin no

# Disable password authentication — keys only
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM no

# Only allow your specific user(s)
AllowUsers deploy

# Disable X11 forwarding (unless you need it)
X11Forwarding no

# Reduce attack surface
MaxAuthTries 3
LoginGraceTime 30

# Change the listening port (optional but reduces bot noise)
# Port 2222

# Use only modern key exchange algorithms
KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com

# Protocol settings
Protocol 2

Reload SSH and verify:

1
2
3
4
5
6
7
# Reload (NOT restart — keeps existing connection alive while testing)
systemctl reload sshd

# In a NEW terminal window, test the connection before closing the current one
ssh deploy@YOUR_SERVER_IP

# If the new connection works, you're safe. If not, debug in your still-open window.

Warning: If you change the port, remember to update your firewall rules and SSH config (~/.ssh/config) before closing your current session.

Step 3: Configure the Firewall (UFW)

UFW (Uncomplicated Firewall) is the right default for most Linux servers. It wraps iptables in a sensible CLI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Default policies: deny all incoming, allow all outgoing
ufw default deny incoming
ufw default allow outgoing

# Allow SSH — use your custom port if you changed it
ufw allow 22/tcp     # or: ufw allow 2222/tcp

# Allow web traffic if you're running a web server
ufw allow 80/tcp
ufw allow 443/tcp

# Allow specific ports as needed (examples)
# ufw allow 5432/tcp    # PostgreSQL (only if needed externally)
# ufw allow from 10.0.0.0/8 to any port 6379  # Redis — only from private network

# Enable the firewall
ufw enable

# Verify rules
ufw status verbose

Never open database ports (5432, 3306, 27017, 6379) to 0.0.0.0/0. If external database access is required, use an SSH tunnel or a private network.

Step 4: Install and Configure fail2ban

fail2ban monitors log files and bans IPs that show signs of brute-force attacks.

1
2
systemctl enable fail2ban
systemctl start fail2ban

Create a local configuration (never modify jail.conf directly — it gets overwritten on upgrades):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
# Ban for 1 hour after 5 failures within 10 minutes
bantime  = 3600
findtime = 600
maxretry = 5

# Email alerts (configure if you have a mail relay)
# destemail = admin@yourdomain.com
# sendername = fail2ban
# mta = sendmail

[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
bantime  = 86400  # 24 hours for SSH brute-force

[sshd-ddos]
enabled  = true
port     = ssh
filter   = sshd-ddos
logpath  = /var/log/auth.log
maxretry = 10
EOF

systemctl restart fail2ban

# Check status
fail2ban-client status
fail2ban-client status sshd

Step 5: Enable Automatic Security Updates

1
dpkg-reconfigure --priority=low unattended-upgrades

This launches an interactive prompt. Select “Yes” to enable automatic updates. The configuration file is at /etc/apt/apt.conf.d/50unattended-upgrades — the defaults are reasonable, but you can tune what gets auto-updated:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# /etc/apt/apt.conf.d/50unattended-upgrades (excerpt)
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}";
    "${distro_id}:${distro_codename}-security";
    // Auto-update all packages, not just security (optional):
    // "${distro_id}:${distro_codename}-updates";
};

// Automatically reboot if required (e.g., kernel update) — set a safe time
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";

// Remove unused packages automatically
Unattended-Upgrade::Remove-Unused-Dependencies "true";

Step 6: Secure Shared Memory

Shared memory (/dev/shm) can be exploited by processes to bypass some security restrictions. Mount it with noexec,nosuid,nodev:

1
2
echo "tmpfs /run/shm tmpfs defaults,noexec,nosuid,nodev 0 0" >> /etc/fstab
mount -o remount /run/shm 2>/dev/null || true

Step 7: Kernel Hardening via sysctl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
cat > /etc/sysctl.d/99-security.conf << 'EOF'
# IP Spoofing protection
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Ignore ICMP broadcast requests
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Disable source packet routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0

# Log Martians (packets with impossible source addresses)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1

# Block SYN attacks
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5

# Ignore redirect packets
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# Disable IPv6 if not using it
# net.ipv6.conf.all.disable_ipv6 = 1

# Restrict dmesg to root
kernel.dmesg_restrict = 1

# Hide kernel pointers
kernel.kptr_restrict = 2
EOF

sysctl -p /etc/sysctl.d/99-security.conf

First Boot Security Checklist

[ ] apt update && full-upgrade
[ ] Non-root sudo user created
[ ] SSH public key installed for new user
[ ] Root login disabled (PermitRootLogin no)
[ ] Password auth disabled (PasswordAuthentication no)
[ ] AllowUsers set to specific user(s)
[ ] UFW enabled with minimal rules (SSH + required ports only)
[ ] fail2ban installed and configured
[ ] Unattended-upgrades enabled
[ ] Shared memory hardened
[ ] sysctl security settings applied
[ ] Tested: can SSH in as deploy user
[ ] Tested: cannot SSH in as root

Part 3: SSH Mastery

SSH is your primary interface to a remote server. Mastering it dramatically improves day-to-day productivity.

Generate Strong Keys

Use Ed25519 — it is smaller, faster, and more secure than RSA-2048. RSA-4096 is also acceptable but generates much larger signatures.

1
2
3
4
5
# Generate an Ed25519 key (on your local machine)
ssh-keygen -t ed25519 -C "deploy-key-2026" -f ~/.ssh/id_ed25519

# For servers that don't support Ed25519 (old setups):
ssh-keygen -t rsa -b 4096 -C "deploy-key-rsa" -f ~/.ssh/id_rsa_4096

Always set a passphrase. Use ssh-agent so you don’t have to type it repeatedly:

1
2
3
4
5
6
# Start agent and add your key (persists until logout)
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

# macOS: add to keychain so it persists across reboots
ssh-add --apple-use-keychain ~/.ssh/id_ed25519

The SSH Config File

~/.ssh/config is the most underused SSH feature. It defines named hosts with all their settings:

# ~/.ssh/config

# Default settings for all hosts
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519

# Production server
Host prod
    HostName 1.2.3.4
    User deploy
    Port 22
    IdentityFile ~/.ssh/id_ed25519_prod

# Staging via jump host
Host staging
    HostName 10.0.1.50
    User deploy
    ProxyJump bastion

# Bastion/jump host
Host bastion
    HostName 5.6.7.8
    User admin
    Port 22

# Development server with port forwarding for local DB access
Host dev
    HostName 9.10.11.12
    User deploy
    LocalForward 15432 localhost:5432   # PostgreSQL tunnel
    LocalForward 16379 localhost:6379   # Redis tunnel

Now ssh prod connects to the right server with the right key, ssh staging automatically tunnels through the bastion, and ssh dev forwards database ports to your localhost.

SSH Multiplexing

SSH multiplexing reuses a single TCP connection for multiple sessions to the same host — dramatically reducing connection time for repeated operations:

# Add to ~/.ssh/config
Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 600   # Keep master connection alive 10 minutes after last session
1
mkdir -p ~/.ssh/sockets

After the first ssh prod connection, subsequent connections to the same host open in milliseconds rather than seconds.

ProxyJump (SSH Bastion Hosts)

Rather than SSH-ing into a bastion and then SSH-ing to an internal server manually, ProxyJump does it in a single command:

1
2
3
4
5
# One-off:
ssh -J bastion@5.6.7.8 deploy@10.0.1.50

# Or via config (see above) — then just:
ssh staging

This is also useful for scp and rsync:

1
rsync -avz -e "ssh -J bastion" deploy@10.0.1.50:/var/backups/ ./local-backups/

Mosh — SSH for Unstable Connections

Mosh is a replacement for SSH that runs over UDP. It handles roaming (changing IP address), intermittent connections, and mobile networks far better than SSH:

1
2
3
4
5
6
7
8
# Install on the server
apt install mosh

# Open the UDP port range in UFW
ufw allow 60000:61000/udp

# Connect from your local machine (requires mosh installed locally too)
mosh deploy@YOUR_SERVER_IP

Once connected, mosh sessions survive laptop sleep, wifi changes, and brief internet outages. Your session is right where you left it when you reconnect.

Persistent Sessions with tmux

tmux (terminal multiplexer) keeps sessions alive on the server even when your SSH connection drops. This is essential for long-running operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Start a named session
tmux new-session -s main

# Detach (session keeps running)
Ctrl+b d

# Reattach from a new SSH connection
tmux attach -t main

# List sessions
tmux ls

See the companion post on tmux essentials for a full reference.

Port Forwarding — Access Internal Services

SSH tunnels let you securely access services on a remote server that aren’t exposed to the internet:

1
2
3
4
5
6
7
8
9
# Local forward: access remote PostgreSQL as localhost:5432
ssh -L 5432:localhost:5432 deploy@YOUR_SERVER_IP -N

# Remote forward: expose your local dev server on the remote host
ssh -R 8080:localhost:3000 deploy@YOUR_SERVER_IP -N

# Dynamic (SOCKS proxy): route all traffic through the server
ssh -D 1080 deploy@YOUR_SERVER_IP -N
# Then configure your browser to use SOCKS5 proxy at localhost:1080

The -N flag means “don’t open a shell, just forward.” Add -f to run it in the background.


Part 4: Firewall — Going Deeper

UFW is the right choice for most servers. For complex multi-homed servers, traffic shaping, or when you need fine-grained stateful rules, nftables is the modern underlying technology.

UFW: Essential Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Status and rules
ufw status verbose
ufw status numbered   # numbered list for easy deletion

# Deleting rules
ufw delete 3          # delete by number
ufw delete allow 80   # delete by rule specification

# Rate limiting (built-in brute-force protection)
ufw limit ssh         # allow SSH but rate-limit connections (max 6/30sec)

# Allow from specific IP only
ufw allow from 203.0.113.5 to any port 5432

# Allow from a subnet
ufw allow from 10.0.0.0/8 to any port 5432

# Logging
ufw logging on
# Logs appear in /var/log/ufw.log

nftables: The Modern Alternative

nftables is the Linux kernel’s modern packet filter (replacing iptables since kernel 3.13). UFW still uses iptables by default, but nftables is the future. Here’s a production-grade nftables configuration:

# /etc/nftables.conf
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Allow established/related connections
        ct state established,related accept

        # Allow loopback
        iif lo accept

        # Drop invalid packets
        ct state invalid drop

        # ICMP — allow ping
        ip protocol icmp icmp type echo-request limit rate 5/second accept
        ip6 nexthdr icmpv6 accept

        # SSH (rate limited)
        tcp dport 22 ct state new limit rate 3/minute burst 5 packets accept

        # HTTP/HTTPS
        tcp dport { 80, 443 } accept

        # Drop everything else (default policy)
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}
1
2
3
4
# Enable nftables
systemctl enable nftables
nft -f /etc/nftables.conf
nft list ruleset

Firewall Decision Guide

Choose When
UFW Single server, standard ports, ease of management prioritized
nftables directly Multi-homed server, complex rules, traffic shaping needed
Cloud firewall (vendor-provided) Defense in depth — add vendor firewall BEFORE the OS-level one

Most VPS providers (DigitalOcean, Hetzner, Linode) offer a cloud firewall that filters traffic before it reaches your server’s network interface at all. Using both is defense in depth: even if UFW is misconfigured, the cloud firewall prevents traffic from reaching the instance.


Part 5: Monitoring and Observability

You cannot manage what you cannot measure. Set up monitoring before you need it.

System-Level Monitoring: Netdata

Netdata provides real-time, per-second metrics for CPU, memory, disk I/O, network, and hundreds of applications — with a beautiful built-in dashboard. The agent is lightweight and installs in one command:

1
2
3
4
5
wget -O /tmp/netdata-kickstart.sh https://get.netdata.cloud/kickstart.sh
sh /tmp/netdata-kickstart.sh --stable-channel --dont-start-it

systemctl enable netdata
systemctl start netdata

By default the dashboard is at http://YOUR_SERVER_IP:19999. Do not expose this port publicly — access it via SSH tunnel:

1
2
ssh -L 19999:localhost:19999 deploy@YOUR_SERVER_IP -N &
# Then browse to http://localhost:19999

Netdata auto-discovers common services: nginx, PostgreSQL, Redis, Docker containers, and more.

Uptime Monitoring: Uptime Kuma

Uptime Kuma is a self-hosted status page and uptime monitor. It can monitor HTTP endpoints, TCP ports, DNS records, and ping, with alerts via Slack, Telegram, Discord, PagerDuty, and email.

1
2
3
4
5
6
7
# Install via Docker
docker run -d \
  --restart always \
  --name uptime-kuma \
  -p 3001:3001 \
  -v uptime-kuma:/app/data \
  louislam/uptime-kuma:latest

Add monitors for each of your services. Set alert thresholds to be paged before users notice.

Resource Alerts: Simple Bash Scripts

For lightweight servers without Grafana, simple cron-based scripts catch disk and memory emergencies:

1
2
3
4
5
6
7
8
9
#!/bin/bash
# /usr/local/bin/check-disk.sh
THRESHOLD=85
USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')

if [ "$USAGE" -gt "$THRESHOLD" ]; then
    echo "DISK ALERT: / is at ${USAGE}% on $(hostname)" | \
    mail -s "Disk Usage Warning" admin@yourdomain.com
fi
1
2
3
4
# Add to crontab
crontab -e
# Check disk every 30 minutes
*/30 * * * * /usr/local/bin/check-disk.sh

Log Management

systemd’s journald captures logs from all systemd services. Key commands:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Tail all logs
journalctl -f

# Logs from a specific service
journalctl -u nginx -f

# Logs since last boot
journalctl -b

# Errors only
journalctl -p err -b

# Last hour
journalctl --since "1 hour ago"

# Set journal size limit (prevent disk fill)
# /etc/systemd/journald.conf
[Journal]
SystemMaxUse=500M
SystemMaxFileSize=50M

For centralized log shipping to Loki, Papertrail, or Logtail, install Vector:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
curl -1sLf https://repositories.timber.io/public/vector/cfg/setup/bash.deb.sh | bash
apt install vector

# /etc/vector/vector.toml — ship to Loki
[sources.journald]
type = "journald"

[sinks.loki]
type = "loki"
inputs = ["journald"]
endpoint = "http://localhost:3100"

[sinks.loki.labels]
host = "{{ host }}"
job = "systemd"

Part 6: Backups

The backup strategy that fails is always the one that wasn’t tested. Backups have three requirements:

  1. Automated — manual backups decay over time
  2. Off-site — a backup on the same server as the data is not a backup
  3. Tested — a backup you’ve never restored from is untested

Restic — Modern, Efficient Backups

Restic is a modern backup tool: encrypted, deduplicated, content-addressable. It supports dozens of backends (S3, B2, SFTP, Rclone).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Install
apt install restic

# Initialize a repository on Backblaze B2 (costs ~$0.006/GB/month)
export B2_ACCOUNT_ID="your-account-id"
export B2_ACCOUNT_KEY="your-application-key"
restic -r b2:your-bucket-name:backups init

# Create a backup
restic -r b2:your-bucket-name:backups backup \
  /var/www \
  /etc \
  /home \
  --exclude=/home/*/.cache \
  --tag "daily"

# List snapshots
restic -r b2:your-bucket-name:backups snapshots

# Restore a specific snapshot
restic -r b2:your-bucket-name:backups restore latest --target /tmp/restore

Automate with a systemd timer (preferred over cron on modern systems):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /etc/systemd/system/restic-backup.service
[Unit]
Description=Restic Backup
After=network.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
ExecStart=/usr/bin/restic -r b2:your-bucket:backups backup /var/www /etc /home --tag daily
ExecStartPost=/usr/bin/restic -r b2:your-bucket:backups forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /etc/systemd/system/restic-backup.timer
[Unit]
Description=Daily Restic Backup

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target
1
2
systemctl enable --now restic-backup.timer
systemctl list-timers  # verify it's scheduled

Borgmatic — Backup Configuration as Code

Borgmatic wraps Borg Backup in a YAML configuration file, adding hooks, healthchecks, and database-aware backup support (PostgreSQL, MySQL, MariaDB, MongoDB, SQLite):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# /etc/borgmatic/config.yaml
source_directories:
    - /var/www
    - /etc
    - /home

repositories:
    - path: ssh://backupserver.example.com:22/./backups/myserver
      label: remote

# PostgreSQL database backup (dumps before archiving)
postgresql_databases:
    - name: myapp
      hostname: localhost
      username: backup_user
      format: directory  # supports parallel restore

retention:
    keep_daily: 7
    keep_weekly: 4
    keep_monthly: 12

compression:
    type: lz4

encryption_passphrase: "your-strong-passphrase-here"

hooks:
    healthchecks: https://hc-ping.com/your-uuid-here
    on_error:
        - echo "Backup failed on $(hostname)" | mail -s "Backup Error" admin@yourdomain.com

Vendor Snapshots — Complement, Not Replace

Every major VPS provider offers snapshot backups. These are valuable for:

  • Disaster recovery — restore the entire server in minutes
  • Before major changes — take a snapshot before upgrading the OS or changing configs

They are not sufficient alone:

  • Stored in the same datacenter as the server
  • Can fail simultaneously with the server in a datacenter incident
  • Usually can only restore the full server, not individual files

Use vendor snapshots for recovery speed. Use Restic/Borg for off-site data protection.


Part 7: Process Management with systemd

Modern Linux servers use systemd to manage services. Avoid Supervisor, PM2, or nohup for production services — systemd is more robust, restarts on failure, integrates with logging, and starts at boot automatically.

Writing a systemd Service Unit

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Documentation=https://github.com/yourorg/myapp
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="NODE_ENV=production"
EnvironmentFile=/opt/myapp/.env.production
ExecStart=/usr/bin/node /opt/myapp/server.js
ExecReload=/bin/kill -HUP $MAINPID

# Restart policy
Restart=on-failure
RestartSec=5
StartLimitInterval=60
StartLimitBurst=5

# Resource limits
LimitNOFILE=65536
MemoryMax=512M

# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data /var/log/myapp

# Standard output goes to journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target
1
2
3
4
5
6
7
systemctl daemon-reload
systemctl enable myapp
systemctl start myapp
systemctl status myapp

# View logs
journalctl -u myapp -f

The security directives (NoNewPrivileges, PrivateTmp, ProtectSystem) provide process-level sandboxing — the service cannot write to system paths, cannot gain elevated privileges, and uses a private /tmp.


Part 8: Web Server and TLS

If your VPS serves web traffic, nginx with Let’s Encrypt is the standard stack.

Install nginx and Certbot

1
2
3
4
5
6
7
8
9
apt install nginx certbot python3-certbot-nginx

# Enable and start
systemctl enable nginx
systemctl start nginx

# Open firewall ports
ufw allow 80/tcp
ufw allow 443/tcp

Obtain a TLS Certificate

1
certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot automatically configures nginx and sets up a renewal cron job. Verify auto-renewal works:

1
certbot renew --dry-run

Secure nginx Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# /etc/nginx/sites-available/yourdomain.com

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # TLS configuration (Certbot populates these)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Hide nginx version
    server_tokens off;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req zone=api burst=20 nodelay;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
    gzip_min_length 1000;

    # Proxy to application
    location / {
        proxy_pass http://127.0.0.1:3000;
        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_read_timeout 60s;
        proxy_connect_timeout 10s;
    }

    # Block common attack patterns
    location ~* \.(php|asp|aspx|jsp)$ {
        return 404;
    }

    # Serve static files directly
    location /static/ {
        alias /opt/myapp/public/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Logging
    access_log /var/log/nginx/yourdomain_access.log;
    error_log /var/log/nginx/yourdomain_error.log;
}

Test and reload:

1
2
nginx -t          # Test configuration syntax
systemctl reload nginx

Test Your Security Headers

After deploying, check your headers at securityheaders.com and your TLS at ssllabs.com/ssltest. A properly configured nginx + Let’s Encrypt setup achieves A+ on both.


Part 9: Quality of Life Improvements

Good tooling makes remote server administration feel effortless rather than tedious.

Shell Configuration

Install zsh and a plugin manager:

1
2
3
4
5
apt install zsh
chsh -s $(which zsh) deploy

# Install Starship prompt (fast, cross-shell, informative)
curl -sS https://starship.rs/install.sh | sh

Add to ~/.zshrc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Starship prompt
eval "$(starship init zsh)"

# History
HISTSIZE=10000
SAVEHIST=10000
HISTFILE=~/.zsh_history
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS

# Essential aliases
alias ll='ls -alFh --color=auto'
alias la='ls -A'
alias ..='cd ..'
alias ...='cd ../..'
alias gs='git status'
alias gp='git pull'
alias dc='docker compose'

# Safety aliases — ask before destructive actions
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'

# Server-specific shortcuts
alias jf='journalctl -f'           # Tail all logs
alias jfu='journalctl -fu'         # Tail specific service: jfu nginx
alias sstatus='systemctl status'   # Service status: sstatus nginx
alias srestart='systemctl restart' # Restart: srestart nginx

# Quick disk usage
alias duh='du -h --max-depth=1 | sort -rh | head -20'

# Show open ports
alias ports='ss -tulpn'

# Show memory usage sorted by process
alias mem='ps aux --sort=-%mem | head -20'

Modern CLI Replacements

These tools drop in as better versions of Unix classics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install modern tools
apt install -y bat eza ripgrep fd-find fzf htop ncdu

# bat: syntax-highlighted cat
alias cat='bat'

# eza: modern ls with tree view and git integration
alias ls='eza --icons --group-directories-first'
alias lt='eza --tree --level=2'

# fd: user-friendly find
# fd pattern   (instead of: find . -name "*pattern*")

# ripgrep: faster grep
# rg pattern   (instead of: grep -r pattern .)

fzf — Fuzzy Finding Everything

fzf transforms how you interact with history, files, and processes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Add to ~/.zshrc:
source /usr/share/doc/fzf/examples/key-bindings.zsh
source /usr/share/doc/fzf/examples/completion.zsh

# Ctrl+R: fuzzy history search (replaces backward-search-history)
# Ctrl+T: fuzzy file picker
# Alt+C:  fuzzy directory jump

# Custom: fuzzy kill a process
fkill() {
  local pid
  pid=$(ps -ef | sed 1d | fzf -m | awk '{print $2}')
  if [ -n "$pid" ]; then
    kill -${1:-9} "$pid"
  fi
}

# Custom: fuzzy connect to a host from SSH config
fssh() {
  local host
  host=$(grep "^Host " ~/.ssh/config | awk '{print $2}' | fzf)
  if [ -n "$host" ]; then
    ssh "$host"
  fi
}

Dotfiles Management

When you set up your second (or tenth) server, you want your configurations to follow you. The two best approaches:

Option 1: chezmoi (recommended)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install
sh -c "$(curl -fsLS get.chezmoi.io)"

# Initialize (from scratch or from existing repo)
chezmoi init --apply https://github.com/yourusername/dotfiles.git

# Add a file to manage
chezmoi add ~/.zshrc

# Push changes to dotfiles repo
chezmoi cd
git add .
git commit -m "Update zsh config"
git push

Option 2: GNU Stow (simpler)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apt install stow

# ~/dotfiles/
#   zsh/
#     .zshrc
#   tmux/
#     .tmux.conf

cd ~/dotfiles
stow zsh      # symlinks .zshrc to ~/
stow tmux     # symlinks .tmux.conf to ~/

Server Editor: Neovim or micro

For quick file edits on the server, you need a capable editor that doesn’t require learning vim modal editing:

1
2
3
4
5
6
# micro: nano replacement with familiar Ctrl-key shortcuts
apt install micro
# Ctrl+S to save, Ctrl+Q to quit — it just works

# Or neovim with a minimal config for server use
apt install neovim

Swap Configuration

VPS providers often give you exactly the RAM you pay for with no swap. Add swap to prevent OOM kills on memory spikes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Check current swap
swapon --show

# Create a 2GB swapfile (for a 2-4GB RAM server)
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

# Make permanent
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# Tune swappiness (default 60 is too aggressive for servers)
echo 'vm.swappiness=10' >> /etc/sysctl.d/99-swappiness.conf
sysctl -p /etc/sysctl.d/99-swappiness.conf

vm.swappiness=10 means the kernel only swaps when RAM is 90% full. For a database server, set it even lower (1–5).


Part 10: Automation — Reproduce Your Setup

After manually configuring your first server, the second should take minutes. The industry standard is Ansible for configuration and Terraform for provisioning.

cloud-init — Bootstrap at First Boot

All major VPS providers support user-data (cloud-init) scripts that run during first boot. Pass this when creating the server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# user-data.yaml
#cloud-config
package_update: true
package_upgrade: true

packages:
  - ufw
  - fail2ban
  - htop
  - git
  - vim
  - curl
  - unattended-upgrades

users:
  - name: deploy
    groups: sudo
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... your-public-key

write_files:
  - path: /etc/sysctl.d/99-security.conf
    content: |
      net.ipv4.tcp_syncookies = 1
      net.ipv4.conf.all.rp_filter = 1
      kernel.dmesg_restrict = 1

runcmd:
  - ufw --force enable
  - ufw allow ssh
  - systemctl enable fail2ban
  - systemctl start fail2ban
  - sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
  - sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
  - systemctl reload sshd

Ansible — Configuration Management

Ansible lets you define your server configuration as code and apply it idempotently:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# playbook/site.yml
- name: Configure VPS baseline
  hosts: all
  become: true
  vars:
    admin_user: deploy
    ssh_port: 22
    allowed_ports:
      - { port: 22, proto: tcp }
      - { port: 80, proto: tcp }
      - { port: 443, proto: tcp }

  tasks:
    - name: Update apt cache and upgrade packages
      apt:
        update_cache: yes
        upgrade: full
        cache_valid_time: 3600

    - name: Install essential packages
      apt:
        name:
          - ufw
          - fail2ban
          - unattended-upgrades
          - htop
          - git
          - vim
          - curl
        state: present

    - name: Create admin user
      user:
        name: "{{ admin_user }}"
        groups: sudo
        shell: /bin/bash
        state: present

    - name: Set up SSH authorized keys
      authorized_key:
        user: "{{ admin_user }}"
        key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"

    - name: Configure UFW defaults
      ufw:
        default: deny
        direction: incoming

    - name: Open required ports
      ufw:
        rule: allow
        port: "{{ item.port }}"
        proto: "{{ item.proto }}"
      loop: "{{ allowed_ports }}"

    - name: Enable UFW
      ufw:
        state: enabled

    - name: Harden SSH config
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        backup: yes
      loop:
        - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
        - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
        - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' }
      notify: Reload SSH

  handlers:
    - name: Reload SSH
      service:
        name: sshd
        state: reloaded
1
2
# Run against a new server
ansible-playbook -i "YOUR_SERVER_IP," -u root playbook/site.yml

Part 11: Additional Security Hardening

AppArmor (Ubuntu) / SELinux (CentOS/RHEL)

AppArmor confines individual programs to a set of listed files and capabilities. Ubuntu ships with AppArmor enabled and profiles for common applications:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Check AppArmor status
aa-status

# List profiles
ls /etc/apparmor.d/

# Put nginx in enforce mode
aa-enforce /etc/apparmor.d/usr.sbin.nginx

# Check for violations
journalctl -u apparmor

Two-Factor Authentication for SSH (TOTP)

For servers accessed by multiple people, add TOTP as a second factor:

1
2
3
4
5
apt install libpam-google-authenticator

# Run as the user who needs 2FA
google-authenticator
# Follow the prompts; scan the QR code in your authenticator app
1
2
3
# /etc/pam.d/sshd
# Add before the existing auth lines:
auth required pam_google_authenticator.so
1
2
3
# /etc/ssh/sshd_config
ChallengeResponseAuthentication yes
AuthenticationMethods publickey,keyboard-interactive

Now SSH requires both your private key AND a TOTP code.

Rootkit Detection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Install rkhunter (rootkit hunter)
apt install rkhunter chkrootkit

# Initialize database of known-good file hashes
rkhunter --propupd

# Run a check
rkhunter --check --skip-keypress

# Baseline check with chkrootkit
chkrootkit

Schedule weekly rkhunter checks via cron and email the output.

Audit Logging with auditd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apt install auditd

# Monitor all commands run by all users
auditctl -a always,exit -F arch=b64 -S execve -k commands

# Monitor writes to sensitive files
auditctl -w /etc/passwd -p wa -k passwd_changes
auditctl -w /etc/sudoers -p wa -k sudoers_changes
auditctl -w /etc/ssh/sshd_config -p wa -k sshd_config

# View audit log
ausearch -k commands | tail -50
aureport --summary

Quick Reference: Master Checklist

Provisioning

[ ] Choose vendor based on geographic requirements and budget
[ ] Select datacenter closest to primary users
[ ] Enable cloud firewall at vendor level before first boot
[ ] Use cloud-init/user-data for automated first-boot setup
[ ] Take initial snapshot after baseline configuration

Security

[ ] apt update && full-upgrade immediately after first boot
[ ] Non-root sudo user with SSH key authentication
[ ] Root SSH login disabled (PermitRootLogin no)
[ ] Password authentication disabled (PasswordAuthentication no)
[ ] UFW enabled with minimum required ports open
[ ] fail2ban installed with SSH jail enabled
[ ] Unattended security upgrades configured
[ ] Swap configured with vm.swappiness=10
[ ] Sysctl security hardening applied
[ ] AppArmor profiles enforced for public-facing services

SSH

[ ] Ed25519 key pair generated and deployed
[ ] ~/.ssh/config configured for all servers
[ ] SSH multiplexing enabled (ControlMaster auto)
[ ] Mosh installed for mobile/unstable connections
[ ] tmux configured for persistent sessions

Monitoring

[ ] Netdata or equivalent agent installed
[ ] Uptime Kuma monitoring all public endpoints
[ ] Disk usage alerts configured (alert at 80%)
[ ] Log rotation configured (journald max size set)
[ ] Automated backup job running and tested
[ ] Last backup restore date recorded

Web (if applicable)

[ ] nginx configured with security headers
[ ] TLS certificate obtained and auto-renewal verified
[ ] SSL Labs A+ rating confirmed
[ ] SecurityHeaders.io A+ rating confirmed
[ ] Rate limiting configured on API endpoints
[ ] Server version hidden (server_tokens off)

Automation

[ ] cloud-init user-data script for reproducible setup
[ ] Ansible playbook (or equivalent) for configuration
[ ] Dotfiles managed and version-controlled
[ ] Runbook written: how to recover from full server loss

Vendor Quick-Pick Guide

Your Situation Recommended Vendor
EU-based, cost is priority Hetzner
Best docs, great DX, US-primary DigitalOcean
Maximum reliability, established business Linode / Akamai
Global distribution, edge nodes Vultr
AWS ecosystem, existing AWS usage AWS Lightsail
High I/O, database server UpCloud
EU GDPR compliance, large scale OVHcloud
Dev/staging, absolute lowest cost Contabo

Verdict

A well-run VPS is not the product of any single tool — it is the cumulative effect of a dozen small disciplines applied on day one and never abandoned. The vendor you pick matters least; the baseline you set in the first thirty minutes matters most. Lock SSH down to keys only, put a default-deny firewall in front of everything, automate the patching, ship your logs and backups off the box, and codify the whole setup so you can rebuild it from scratch in an afternoon. Do that and the gap between a budget Hetzner instance and a managed platform costing ten times as much narrows to almost nothing. The machine is cheap; the discipline is the value.


Further Reading on This Blog


Sources

Comments