Setting Up Your Own Server, From Zero to Hardened
How I chose a cheap VPS, got in with SSH keys, and hardened it in seven steps before installing anything, including the Docker firewall gap most guides skip.

A walkthrough of standing up and securing a small VPS that will serve web products over HTTP and HTTPS: choosing a provider, getting in with SSH, and hardening the machine before installing anything. Each step explains what it does, why it matters, and what goes wrong without it.
This article covers getting a server from a fresh order to a hardened machine, ready to host whatever the products need: websites, APIs, databases, analytics. Nothing here depends on what you'll run on it afterwards; the one assumption is that, like most modern setups, those services will run as Docker containers, which matters for the firewall (steps 4 and 7).
1. Why my own server
In the next few weeks I'll be building a bunch of web products. Each one needs roughly the same pieces: a database, an API, analytics, a few background services. Using a managed service for each piece, for each product, adds up quickly: a hosted database here, an analytics subscription there, another platform for the API, every one of them billing monthly, multiplied by the number of products.
So I decided to host those pieces myself, on one server I control:
- One fixed monthly cost instead of a stack of subscriptions that grows with every product.
- One place to run everything, each product's services in their own containers, side by side.
- Low cost, one person running it. That rules out anything that needs constant babysitting, and it's why so much of this article is about automating security rather than watching it.
- Room to grow. Start small, and grow the server, or add a second one, as the products get real users, without starting over.
The trade-off is that I become the system administrator. The rest of this article is about doing that job properly from day one.
2. Choosing a provider
Comparing the options
Prices for roughly 8 GB of RAM in North American regions, checked in October 2026:
| Provider | Roughly 8 GB of RAM | Notes | |---|---|---| | Budget VPS from a large European host | CAD ~12 to 15/mo | Cheapest per GB, anti-DDoS included, monthly billing | | Hetzner Cloud | USD ~73/mo in the US | Cheap in Europe, far less so in the US | | Hostinger | USD ~9/mo promo | Promo needs 2 years prepaid, and the price doubles on renewal | | Bluehost | USD ~13/mo promo | Same 2-year prepay model | | AWS Lightsail | USD 44/mo | Reliable, simple, about 4 to 5 times the cheapest | | DigitalOcean | USD 48 to 63/mo | Nice console, expensive | | Budget dedicated servers | ~CAD 25 for 32 GB | A whole physical machine, but older hardware |
Two lessons came out of the comparison:
- The same provider can cost very different amounts by region. Hetzner's US prices were several times its European ones. Always check prices in the order form for the exact region you'll use, ideally one close to your users.
- Look at the upfront cost, not just the monthly figure. An attractive promo price can mean paying two years in advance, then double on renewal.
What I chose: start small, upgrade later
I took a small VPS (2 vCores, 4 GB of RAM) from the cheapest provider, billed monthly with no commitment. It will run the first products, each part (website, API, database, analytics) in its own container.
Why this works:
- It's enough to start. See the numbers below.
- No commitment. Monthly billing means no money is locked in if needs change.
- Growth without starting over. When the products need more, the provider can usually upgrade the VPS in place, keeping its data and IP (often upward only, so don't over-buy hoping to shrink later). Later, a second server can take the databases or new products.
A 4 GB VPS is enough for a couple of early-stage products: a website, a small API, Postgres and analytics each, plus a reverse proxy and Docker itself, fit in around 2 to 3 GB in practice. Add swap (step 6) as a safety net. It stops being enough as products get real traffic, and monitoring (more on that at the end) tells you well before then.
3. Checking the IP addresses before using them
IP addresses get recycled. When a customer deletes a server, its address goes back to the pool, and the next customer inherits its reputation. An IP that sent spam or attacked other servers may sit on blocklists that mail providers and some firewalls consult.
Check each new IP two ways:
- Spam blocklists: Spamhaus, Barracuda, SpamCop, UCEPROTECT and others. Query them with
dig, or use MXToolbox's blacklist check, which covers about 80 lists at once. Spamhaus ignores queries from some public DNS resolvers and simply returns nothing, so check that your lookups work by querying its test address first (dig +short 2.0.0.127.zen.spamhaus.orgshould answer). - Abuse history: AbuseIPDB and Cisco Talos for reports of attacks and scanning; Shodan for what was exposed on the IP recently.
If a server doesn't send email itself (for example, you send through a service like Amazon SES), a listed IP is mostly a nuisance. But checking takes seconds, and replacing an IP after DNS points at it is real work. Some providers let you reserve an IP separately ("Primary IP" at Hetzner) and check it before creating the server; if it's dirty, delete it and take another.
4. SSH: how you get into the server
What SSH is
SSH (Secure Shell) is an encrypted connection to a remote machine's terminal. This server has no screen and no graphical interface, so every command you type after ssh <server> runs on the server, not on your computer.
Keys instead of passwords
SSH can authenticate you with a key pair instead of a password. Create one dedicated to this server:
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_vps -C "my-vps"
| File | What it is | Where it lives |
|---|---|---|
| id_ed25519_vps | The private key, your secret | Only on your computer, never shared |
| id_ed25519_vps.pub | The public key, the matching padlock | Copied onto the server |
Think of a padlock and its key. The padlock (public key) goes on the server's door, and anyone may see it. Only the key (private key) opens it. The passphrase you set encrypts the private key on disk, so a stolen laptop or a copied file isn't enough to use it.
Can someone guess the key? No. An Ed25519 key has about 2¹²⁸ possibilities. Even a billion billion guesses per second would take longer than the age of the universe, and over the network a bot manages a few guesses per second at best. The real risks are elsewhere:
- someone copying the private key file,
- a weak passphrase on a stolen file,
- a server that still accepts passwords, which bots can guess.
The last one is the reason for hardening step 3.
Give the public key to the provider when ordering if the form allows it. It saves the detour described below.
The ~/.ssh/config file: shortcuts
Instead of typing ssh -i ~/.ssh/id_ed25519_vps ubuntu@203.0.113.10 every time, ~/.ssh/config is an address book:
Host my-vps
HostName 203.0.113.10
User ubuntu
IdentityFile ~/.ssh/id_ed25519_vps
IdentitiesOnly yes
AddKeysToAgent yes
UseKeychain yes
Now ssh my-vps is enough. Line by line:
IdentitiesOnly yesoffers only this key to this server. By default SSH may try every key it knows, one after another, like trying every key on a key ring at a door with a guard. Each wrong key counts as a failed login. With fail2ban watching (step 5), a few wrong keys could get your own IP banned. It also avoids showing the server your other public keys, which might identify you: GitHub, for example, publishes users' public keys. The default (off) is fine when you have a single key, or don't know which key a server expects.AddKeysToAgentandUseKeychain(macOS) keep the unlocked key in the system keychain, so you type the passphrase once rather than on every connection or after every reboot.
The fingerprint question on the first connection
The authenticity of host '203.0.113.10' can't be established.
ED25519 key fingerprint is SHA256:…
Are you sure you want to continue connecting (yes/no)?
The server proves its identity too. The first time, your computer doesn't know it yet, so it asks. Compare it with a fingerprint you obtained another way: the provider's panel, or ssh-keyscan -t ed25519 <ip> | ssh-keygen -lf - run beforehand. If they match, answer yes. It's then saved in ~/.ssh/known_hosts. If the server ever presents a different identity without having been rebuilt, SSH refuses to connect. That's how it detects someone placed between you and your server.
When the key isn't on the server
If the first connection asks for a password rather than your key's passphrase, the server refused your key: it was never installed, usually because the order form didn't ask for it. The provider will have emailed a password instead (often one you must change on first login). The fix:
- Log in once with the emailed password, and change it if required.
- Install your public key:
ssh-copy-id -i ~/.ssh/id_ed25519_vps.pub my-vps
ssh-copy-id reads the .pub file (never the private key), logs in with the password one last time, and appends that one line to ~/.ssh/authorized_keys on the server. That file is the server's list of padlocks allowed to open the account. The manual equivalent is three commands: create ~/.ssh, append the line, set the permissions to 600.
A trap here: logging in with the password is not the same as installing the key. Before going further, check that the key alone works:
ssh -o BatchMode=yes my-vps true && echo "key works"
The next steps turn passwords off, and doing that without a working key locks you out of your own server.
5. Hardening, step by step
A fresh VPS typically arrives with only SSH listening, which is good. But passwords are often still accepted, the firewall is off, and there's no swap.
Step 1: update everything
sudo apt update # fetch the list of available updates
sudo apt full-upgrade # install them, including a new kernel
cat /var/run/reboot-required
sudo reboot
An image is built weeks or months before you receive it, so it starts with known, already-fixed vulnerabilities. Expect over a hundred updates, often including a new kernel. The kernel is loaded into memory at boot, so a new one only runs after a reboot. Check with uname -r once the server is back.
Step 2: automatic security updates, including the reboot
Ubuntu's unattended-upgrades usually comes enabled and installs security patches daily. What it isn't allowed to do by default is reboot, so kernel patches install and then sit there, protecting nothing. Allow it, with your own settings file rather than editing Ubuntu's, so updates can't overwrite it and it's obvious what you changed:
sudo timedatectl set-timezone America/Toronto
sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-local <<'EOF'
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
EOF
apt-config dump | grep Automatic-Reboot
- Pick a quiet hour. The server only reboots when a patch requires it, a few times a month at most, and is back within a minute or two.
- Setting the time zone makes logs and schedules readable in your local time.
Step 3: SSH by key only
Bots start trying passwords within hours of a server going online. On our server, the logs already showed hundreds of failed SSH attempts on the first day, trying usernames like user, testuser, deploy and admin. You can see yours with sudo journalctl -u ssh --since today | grep "Invalid user".
Turning passwords off is one setting, but it may hide a trap. SSH reads its settings from small files in /etc/ssh/sshd_config.d/, in name order, and for each setting it keeps the first value it reads. Cloud images often ship two contradictory files:
50-cloud-init.conf → PasswordAuthentication yes (read first, wins)
60-cloudimg-settings.conf → PasswordAuthentication no (ignored)
Ubuntu's image ships 60 with the safe default. At first boot, cloud-init, the program that configures a new server from the provider's instructions, writes 50 when the provider delivered a password instead of a key, so that the password can work. It's a deliberate exception, not a bug.
Rather than delete 50 (cloud-init or an update could recreate it), add a file numbered 00, read before everything else:
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
X11Forwarding no
EOF
sudo sshd -t # syntax check: no output means no error
sudo sshd -T | grep -E "passwordauthentication|permitrootlogin|maxauthtries"
sudo systemctl restart ssh
PermitRootLogin prohibit-passwordmeansrootcan only log in with a key, and a fresh VPS usually has no root key at all, so in practice nobody can log in as root. You do your work as the normal user withsudo. UsePermitRootLogin noto close the door entirely;prohibit-passwordleaves it ajar for automation tools that need root over SSH with their own dedicated key.- The safety rule: keep one terminal connected while changing SSH, and test the new configuration from a second terminal. A mistake then costs a fix, not a lockout.
- The test:
ssh -o PubkeyAuthentication=no my-vpsshould answerPermission denied (publickey). Onlypublickeyis offered now, where before it said(publickey,password).
Step 4: the firewall (ufw)
A firewall decides which incoming connections are accepted. Without one, any program that starts listening on a port is immediately reachable from the internet, even by mistake. The rule is deny by default, then open only what's needed:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH, allowed FIRST, or enabling cuts your own session
sudo ufw allow 80/tcp # HTTP, mainly for HTTPS certificates and redirects
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
sudo ufw status verbose
- IPv6: ufw applies the same rules to IPv6 automatically; those are the
(v6)lines. - 80 and 443 may still look closed from outside. They will until something, typically a reverse proxy such as Caddy, Traefik or Nginx, listens on them.
ufw has a well-known blind spot: Docker. When a container "publishes" a port (-p 5432:5432, or ports: in a compose file), Docker writes its own rules directly into the kernel's packet filter, and those rules are evaluated before ufw's. The port is reachable from the whole internet even though ufw status doesn't show it and ufw's default says deny.
This is easy to trigger by accident: a database container published "just to connect from my laptop", or a compose file copied from a tutorial. That's why a second firewall comes in step 7, one that Docker can't touch, and why you should scan your server from outside after starting any container.
Step 5: fail2ban
fail2ban watches the logs and, when an IP fails too often, bans it at the firewall for a while. The bots stop reaching SSH at all.
sudo apt install fail2ban
sudo tee /etc/fail2ban/jail.d/sshd.local <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
bantime.increment = true
EOF
sudo systemctl restart fail2ban && sudo systemctl enable fail2ban
sudo fail2ban-client status sshd
- The thresholds: five failures within ten minutes earn a one-hour ban, and
bantime.incrementmakes repeat offenders' bans longer each time. Banning on the first failure would punish your own typos. - The file location: settings go in a
.localfile, never the shipped.conf, so updates don't overwrite them. - Expect quick results. Within minutes of enabling it,
fail2ban-client status sshdusually shows the first banned IPs.
Step 6: swap, and a cap on logs
Swap is a file on disk that the system uses as emergency memory when RAM is full. It's slow, so it's an airbag, not extra RAM. Without it, a brief memory spike on a small server forces Linux to kill a process, possibly your database. With a couple of GB of swap, it moves rarely used memory to disk instead.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile # swap can contain fragments of memory, passwords included
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab # survive reboots
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl -p /etc/sysctl.d/99-swappiness.conf
free -h
swappiness (0 to 100) is how eagerly Linux uses swap. The default of 60 is eager; at 10, it prefers RAM and only swaps when it really has to.
journald, the system logbook, may by default use up to 10% of the disk, or 4 GB. With bots knocking around the clock, logs grow, so cap it. 500 MB is still weeks of history:
sudo mkdir -p /etc/systemd/journald.conf.d
printf "[Journal]\nSystemMaxUse=500M\n" | sudo tee /etc/systemd/journald.conf.d/00-size.conf
sudo systemctl restart systemd-journald
journalctl --disk-usage
Step 7: the provider's network firewall
Many providers offer a firewall that runs on their network, before traffic reaches your server. It's usually free, often sits next to the provider's anti-DDoS protection, and is configured in the provider's panel, typically on the page that lists your IP addresses.
Why add it when ufw is already there? Because of containers. Everything on the server, ufw included, can be bypassed by something else running on the server, and Docker does exactly that when it publishes a port (step 4). A firewall on the provider's network is outside the machine: no container, misconfigured compose file or software update can open a port in it. If a database container ends up published by mistake, the provider's firewall still drops the traffic before it arrives.
Ours is stateless: it doesn't remember conversations. When your server asks for something, such as a DNS lookup or a package download, the reply comes back from outside and needs its own rule. That's what rules 0, 2 and 3 are for:
| # | Action | Protocol | Detail | Why | |---|---|---|---|---| | 0 | Allow | TCP | established | Replies to connections the server opened | | 1 | Allow | ICMP | | Ping and network error messages | | 2 | Allow | UDP | source port 53 | DNS replies | | 3 | Allow | UDP | source port 123 | Clock synchronisation replies | | 4 | Allow | TCP | destination port 22 | SSH | | 5 | Allow | TCP | destination port 80 | HTTP | | 6 | Allow | TCP | destination port 443 | HTTPS | | 19 | Refuse | IPv4 | everything | All the rest |
- Order: lower numbers are checked first, so the catch-all refusal goes last.
- Source vs destination: a reply comes from port 53 (source), while a visitor connects to your port 443 (destination).
- Limits: ours covers IPv4 only and allows 20 rules, so IPv6 stays covered by ufw. Check what yours supports.
- Activate last. Add all the rules, wait until each one shows as created, then switch the firewall on.
A mistake worth remembering. While entering the rules, one was saved without its port: the page refreshed while the port number was being typed. A TCP rule with no port means "allow all TCP", which would have quietly cancelled the protection. It was caught by rereading the whole list, then deleted and recreated before switching the firewall on. Always reread every rule before activating: one empty field changes what a rule means.
Test from outside after activating:
- From the server: SSH in, then check that DNS (
getent hosts archive.ubuntu.com), HTTPS (curl -I https://example.com), clock sync (timedatectl) andsudo apt updatestill work. If they do, the reply rules are right. - From your computer: probe a few ports that should be closed, such as 3000, 5432 (Postgres) and 8080, with
nc -z -G 4 <ip> <port>(or scan withnmap). They should be filtered. Repeat this after every change that adds a container.
6. The result: layers of protection
From the outside in:
- The provider's anti-DDoS absorbs volume floods.
- The provider's network firewall drops everything except SSH, the web and replies, before it reaches the server.
- ufw does the same on the server, and covers IPv6.
- fail2ban bans whoever keeps knocking on SSH.
- SSH accepts only a key that never leaves your computer.
- Automatic patches and reboots close known vulnerabilities without anyone remembering to.
None of these layers is enough alone. Together, an attacker has to get through all of them, and a mistake in one, like a mistyped firewall rule or a Docker-published port, is caught by another.
7. What comes next
- Two-factor authentication on the provider account. That account can reinstall, reconfigure or delete everything above, so it's the last protection to add before going further.
- DNS:
A(IPv4) andAAAA(IPv6) records pointing your domains at the server, so HTTPS certificates can be issued as soon as the web server starts. - The web stack, as containers:
- Install Docker from its official repository.
- Put a reverse proxy (Caddy, Traefik or Nginx) in front. It's the only container that publishes ports, 80 and 443, and it obtains and renews the HTTPS certificates.
- Let every other container (website, API, database, analytics) talk to the proxy over a private Docker network, without publishing any port. If you must publish one, bind it to the server itself only:
127.0.0.1:5432:5432instead of5432:5432. - Scan the server from outside again.
- Monitoring and backups:
- Monitoring with alerts: a lightweight agent (Beszel, Netdata) for CPU, memory and disk, plus external checks (UptimeRobot for uptime, Healthchecks.io for scheduled jobs) that keep working when the server is down.
- Backups off the server: an S3-compatible bucket at a different provider or location, and a restore you've actually tested.
Liked this? Get the next one in your inbox.
One email every other Tuesday. Engineering, product, and what I'm shipping. Unsubscribe anytime.