Level 1 — Creating and Connecting to a VPS
You will create a server, generate a cryptographic key pair, and use it to log in. By the end you will have a shell prompt on a machine in a datacenter.
Creating a VPS
The examples use Hetzner Cloud, but the choices are the same everywhere.
Choosing a location
Pick the region closest to your users, not to you. Network latency is physics: Nuremberg → Tashkent is ~90 ms round trip, and every uncached API call pays it.
| Provider | Regions relevant to Central Asia / Europe |
|---|---|
| Hetzner | Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore |
| DigitalOcean | Frankfurt, Amsterdam, London, Bangalore, Singapore |
| Vultr | Frankfurt, Warsaw, Mumbai, Tokyo |
Choosing CPU, RAM and storage
The honest answer: start small, resize later. Hetzner and most providers let you upgrade CPU/RAM in a couple of minutes of downtime. Disk can usually grow but not shrink.
| Plan | Good for |
|---|---|
| 2 vCPU / 4 GB / 40 GB | A Nuxt + NestJS + PostgreSQL + Redis stack in early production. This is the realistic starting point. |
| 1 vCPU / 2 GB / 20 GB | Staging, or a single small app. Tight once you add a build step. |
| 4 vCPU / 8 GB / 80 GB | Real traffic, or when you build on the server. |
| 8 vCPU / 16 GB+ | You should be measuring, not guessing, by this point. |
Sizing rules of thumb:
- RAM is the binding constraint, not CPU. A Node process idles at 80–150 MB and grows. PostgreSQL wants 25% of system RAM for
shared_buffers. Redis holds its entire dataset in memory. pnpm buildfor Nuxt can peak above 2 GB. On a 2 GB server it will be OOM-killed. Either build in CI (recommended — Level 18) or add swap (Level 21).- Disk fills faster than you expect: Docker images, old build artifacts, PM2 logs, and PostgreSQL WAL all accumulate. Budget 40 GB minimum and monitor it.
x86 vs ARM
Hetzner's CAX (ARM64/Ampere) instances are ~30% cheaper for the same specs. Node.js, PostgreSQL, Redis, and Nginx all have first-class ARM64 builds. The risk is a native npm dependency without a prebuilt ARM binary — it will compile from source (slow) or fail. Check your dependency tree before committing. For a standard Nuxt/NestJS/Prisma stack, ARM works fine.
Choosing the image
Select Ubuntu 24.04 LTS. Not 24.10, not "latest", not Debian unless you have a reason.
At creation time: add your SSH key
Every provider offers an "SSH keys" field during creation. Use it. The provider installs your public key into /root/.ssh/authorized_keys before first boot, and no root password is ever emailed to you. If you skip this, the provider mails you a root password — which means a password-authenticating root account is exposed to the internet from the moment the machine boots, and bots will be trying it within minutes.
Generate the key first (next section), then paste the public key.
Cloud firewall
Hetzner, DigitalOcean, and AWS all offer a network-level firewall that runs outside your VM. Configure it in addition to UFW:
| Direction | Port | Source |
|---|---|---|
| Inbound | 22 (TCP) | Your IP, or 0.0.0.0/0 if your IP is dynamic |
| Inbound | 80 (TCP) | 0.0.0.0/0 |
| Inbound | 443 (TCP) | 0.0.0.0/0 |
| Outbound | all | allow |
Two layers is not paranoia: if you misconfigure UFW and lock yourself out, the cloud firewall is separate; if you mess up the cloud firewall, UFW still guards you. And a cloud firewall drops packets before they reach your VM, so brute-force traffic never costs you CPU.
SSH
SSH (Secure Shell) is an encrypted protocol for running a shell on a remote machine. It is your only way in. Everything about server administration flows through it.
The server runs sshd (the SSH daemon), listening on TCP port 22. Your laptop runs the ssh client.
The critical property: your private key never leaves your machine. The server sends a random challenge, you sign it, the server verifies the signature with the public key it already holds. Even a fully compromised server never learns your private key.
Public/private key architecture
A key pair is two mathematically linked files:
| File | Name | Where it lives | Share it? |
|---|---|---|---|
| Private key | id_ed25519 | Your laptop, ~/.ssh/, mode 600 | Never |
| Public key | id_ed25519.pub | Copied to servers, authorized_keys | Freely |
Anything encrypted with the public key can be decrypted only by the private key, and a signature made with the private key can be verified by anyone holding the public key. Deriving the private key from the public key is computationally infeasible.
YOUR PRIVATE KEY IS YOUR IDENTITY
Anyone with your private key can log into every server that trusts it. Never paste it into a chat, commit it to Git, email it, or upload it anywhere. The .pub file is the one you share — check the extension every single time.
Password authentication vs key authentication
| Password | SSH key | |
|---|---|---|
| What is transmitted | The password itself (encrypted in transit) | Only a signature |
| Brute-forceable | Yes — bots try thousands per hour | No — 2^128 search space |
| Phishable | Yes | No |
| Reused across services | Usually, by humans | Never |
| Works unattended (CI/CD) | Badly | Yes |
| Server compromise leaks it | Yes | No |
PRODUCTION BEST PRACTICE
Disable password authentication entirely (Level 4). This single change eliminates SSH brute-force as a threat category. Your logs will still show thousands of attempts per day; none of them can succeed.
How to generate SSH keys
Run this on your laptop, not the server.
# LOCAL
ssh-keygen -t ed25519 -C "aziz@laptop-2026" -f ~/.ssh/id_ed25519| Flag | Meaning |
|---|---|
-t ed25519 | Key type. Ed25519 is the modern default. |
-C "..." | A comment stored in the public key. Use something that identifies which machine the key lives on — invaluable when auditing authorized_keys two years from now. |
-f ~/.ssh/id_ed25519 | Output path. The .pub file is created alongside. |
You will be prompted for a passphrase:
Enter passphrase (empty for no passphrase):Use one. The passphrase encrypts the private key file at rest. If your laptop is stolen, the thief has an encrypted blob, not your servers. The obvious objection — "then I have to type it constantly" — is solved by ssh-agent (below), which asks once per boot.
The exception is CI/CD keys, which must have no passphrase because no human is present to type it. Compensate by making them narrowly scoped and rotating them (Level 18).
Ed25519 vs RSA
| Ed25519 | RSA | |
|---|---|---|
| Key size | 256-bit | 3072 or 4096-bit needed |
| Public key length | One short line | Multiple wrapped lines |
| Speed | Much faster | Slower |
| Security | ~128-bit equivalent | Comparable at 3072+ |
| Support | OpenSSH 6.5+ (2014) | Universal |
| Vulnerable to bad randomness | Resistant by design | Yes |
Use Ed25519. Only fall back to RSA if you hit ancient hardware or a legacy system:
# LOCAL — only if Ed25519 is unsupported
ssh-keygen -t rsa -b 4096 -C "aziz@laptop-2026"NEVER USE DSA OR ECDSA-WITH-NIST-CURVES
DSA is disabled in modern OpenSSH and removed entirely in OpenSSH 9.8+. ECDSA relies on NIST curves whose parameter provenance is disputed. If you have old id_dsa keys, replace them.
Inspecting your keys
# LOCAL
cat ~/.ssh/id_ed25519.pubssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8x2vQ... aziz@laptop-2026Three fields: algorithm, base64 key material, comment.
# LOCAL — show the fingerprint (a short hash used to identify the key)
ssh-keygen -lf ~/.ssh/id_ed25519.pub256 SHA256:9Xk2vQ8mN... aziz@laptop-2026 (ED25519)How to copy a public key to a server
Method 1 — ssh-copy-id (easiest)
# LOCAL
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@203.0.113.10This logs in (with a password, this one time), creates ~/.ssh with mode 700, appends your key to authorized_keys, and sets mode 600. It handles all the permission details that trip people up.
Method 2 — provider web console
Paste the contents of the .pub file into the provider's "SSH Keys" section, then attach it when creating the server. This is the preferred method — no password ever exists.
Method 3 — manual (when ssh-copy-id is unavailable)
# LOCAL — one command, correct permissions included
cat ~/.ssh/id_ed25519.pub | ssh root@203.0.113.10 \
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"USE >> NOT >
> overwrites the file, deleting every other key including the one you are currently connected with. >> appends. This mistake locks people out permanently.
Connecting
# LOCAL
ssh root@203.0.113.10Breaking it down:
| Part | Meaning |
|---|---|
ssh | The client program |
root | The remote username to log in as |
@ | Separator |
203.0.113.10 | Server IP (or hostname, once DNS is set up) |
Implicit defaults: port 22, and identity files ~/.ssh/id_ed25519, ~/.ssh/id_rsa, etc., tried in order.
Specifying a port
# LOCAL
ssh -p 2222 deploy@203.0.113.10-p sets the port. Needed only if you moved sshd off 22 (Level 4 discusses whether you should).
-p IS SSH, -P IS SCP
ssh uses lowercase -p; scp uses uppercase -P. Mixing them up is a rite of passage.
Specifying a key
# LOCAL
ssh -i ~/.ssh/hetzner-prod deploy@203.0.113.10-i ("identity file") points at a specific private key. Necessary when you keep separate keys per server — which you should, so that revoking access to one machine does not mean rotating everywhere.
Running one command and exiting
# LOCAL
ssh deploy@203.0.113.10 "pm2 list"No interactive shell — SSH runs the command, prints the output, and disconnects. This is how CI/CD deploys work.
NON-LOGIN SHELLS DO NOT SOURCE YOUR PROFILE
ssh host "pm2 list" runs a non-interactive, non-login shell. It does not read ~/.bashrc (or exits early from it), so NVM is never loaded and you get pm2: command not found — even though the exact same command works when you SSH in normally. This is the single most common CI/CD deployment failure. Fix in Level 18.
The SSH config file
Rather than retyping flags, use ~/.ssh/config on your laptop:
# ~/.ssh/config
Host prod
HostName 203.0.113.10
User deploy
Port 22
IdentityFile ~/.ssh/hetzner-prod
IdentitiesOnly yes
ServerAliveInterval 60
ServerAliveCountMax 3
Host staging
HostName 203.0.113.20
User deploy
IdentityFile ~/.ssh/hetzner-staging
IdentitiesOnly yesNow ssh prod is enough. And so is scp file.tar.gz prod:/tmp/.
| Option | Why |
|---|---|
IdentitiesOnly yes | Offer only the named key. Without it, SSH offers every key in your agent; after 6 failures the server disconnects with "Too many authentication failures" even though the right key was in the list. |
ServerAliveInterval 60 | Send a keepalive every 60s so idle sessions are not dropped by NAT/firewall timeouts. |
ServerAliveCountMax 3 | Give up after 3 unanswered keepalives (3 min). |
# LOCAL
chmod 600 ~/.ssh/config~/.ssh — the directory that controls everything
On both machines:
| Path | Purpose | Required mode |
|---|---|---|
~/.ssh/ | The directory itself | 700 (drwx------) |
~/.ssh/id_ed25519 | Your private key | 600 (-rw-------) |
~/.ssh/id_ed25519.pub | Your public key | 644 |
~/.ssh/authorized_keys | Public keys allowed to log in as this user | 600 |
~/.ssh/known_hosts | Server identities you have accepted | 644 |
~/.ssh/config | Client shortcuts (laptop only) | 600 |
SSH REFUSES TO WORK WITH LOOSE PERMISSIONS
If ~/.ssh is group- or world-writable, sshd silently ignores authorized_keys and you get "Permission denied (publickey)" with no useful error on the client. The reason: if another user can write your authorized_keys, they can add their own key and become you. This is the number one cause of mysterious key-auth failures.
Fix on the server:
# SERVER
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R $USER:$USER ~/.sshAlso check the home directory itself — /home/deploy must not be group-writable: chmod 755 /home/deploy.
authorized_keys
One public key per line, on the server, in the home directory of the user you log in as.
# /home/deploy/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8x2... aziz@laptop-2026
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK9y3... github-actions-deployYou can restrict what a key is allowed to do by prefixing options — useful for CI keys:
from="140.82.0.0/16",no-agent-forwarding,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... ci-deploycommand="..." can even force a key to run only one specific script, no matter what the client asks for. That is the strongest form of CI key restriction.
known_hosts
On your laptop. Records the public host key of each server you have connected to. First connection:
The authenticity of host '203.0.113.10' can't be established.
ED25519 key fingerprint is SHA256:4jK9mN2vQ8xR...
Are you sure you want to continue connecting (yes/no/[fingerprint])?SSH is telling you: I have never seen this server before and cannot verify it is who it claims to be. Strictly, you should compare the fingerprint against the one shown in your provider's console or server logs. In practice most people type yes on first connect over a trusted network and rely on the warning if it ever changes.
If the key later changes, you get:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!Legitimate causes: you rebuilt the server, you reinstalled the OS, the IP was reassigned to a different customer. Malicious cause: someone is intercepting your connection.
Think before you clear it. If you did not just rebuild that server, investigate. If you did:
# LOCAL
ssh-keygen -R 203.0.113.10This removes only that host's entry. Never delete the whole known_hosts file — you would lose the protection for every other server.
ssh-agent
ssh-agent holds decrypted private keys in memory so you type your passphrase once per session instead of once per connection.
# LOCAL — start the agent (usually already running)
eval "$(ssh-agent -s)"
# Add a key (prompts for passphrase once)
ssh-add ~/.ssh/id_ed25519
# List loaded keys
ssh-add -l
# Remove all keys (e.g. before walking away)
ssh-add -DOn macOS, the keychain integrates with the agent. Add to ~/.ssh/config:
Host *
UseKeychain yes
AddKeysToAgent yesNow the passphrase is stored in the macOS keychain and loaded automatically at login.
Agent forwarding — and why to avoid it
ssh -A forwards your agent to the server so you can, for example, git clone a private repo from there using your laptop key.
AGENT FORWARDING IS A REAL RISK
Anyone with root on the forwarded-to server can use your agent socket to authenticate as you to any other server, for as long as your session is open. They cannot steal the key, but they do not need to.
Use a deploy key on the server instead (Level 7). If you truly need forwarding, prefer ssh -o ForwardAgent=yes for that one connection rather than putting ForwardAgent yes in your config, and never forward to a machine you do not fully control.
Debugging SSH connection problems
ssh -v
Verbosity is the primary tool. One -v is usually enough; -vvv shows everything.
# LOCAL
ssh -v deploy@203.0.113.10Lines that matter in the output:
| Output line | Means |
|---|---|
Connecting to 203.0.113.10 [203.0.113.10] port 22. | DNS resolved; TCP attempt starting |
Connection established. | TCP succeeded — network and firewall are fine |
Server host key: ssh-ed25519 SHA256:... | Server identity received |
Authentications that can continue: publickey | Password auth is disabled server-side |
Offering public key: /Users/aziz/.ssh/id_ed25519 | Client is trying this key |
Server accepts key | The key is in authorized_keys |
Authenticated to 203.0.113.10 | Success |
If you never see Connection established, the problem is network/firewall, not keys. If you see it but authentication fails, the problem is keys/permissions. This one distinction narrows every SSH problem in half.
Server-side diagnosis
If you still have any working session, watch the auth log live while you attempt to connect from another terminal:
# SERVER
sudo journalctl -u ssh -fThe server-side message is almost always more specific than the client's:
Authentication refused: bad ownership or modes for directory /home/deploy"Permission denied (publickey)"
The server rejected your key. Work through this in order:
| # | Check | Command |
|---|---|---|
| 1 | Are you using the right username? | ssh -v shows it. root vs deploy vs ubuntu varies by provider. |
| 2 | Is the right key being offered? | Look for Offering public key: in -v output |
| 3 | Is the key in authorized_keys? | ssh-keygen -lf ~/.ssh/id_ed25519.pub locally, compare fingerprint to server-side ssh-keygen -lf ~/.ssh/authorized_keys |
| 4 | Are permissions right on the server? | ls -la ~/ ~/.ssh/ — need 755 home, 700 .ssh, 600 authorized_keys |
| 5 | Is authorized_keys owned by the right user? | chown -R deploy:deploy /home/deploy/.ssh |
| 6 | Is the key loaded in the agent? | ssh-add -l |
| 7 | Too many keys offered? | Add IdentitiesOnly yes |
| 8 | Is PubkeyAuthentication yes in sshd config? | sudo sshd -T | grep -i pubkey |
| 9 | Is the user in AllowUsers/AllowGroups? | sudo sshd -T | grep -i allow |
sshd -T IS THE GROUND TRUTH
sudo sshd -T prints the effective configuration after all includes and defaults are resolved. Ubuntu 24.04 ships /etc/ssh/sshd_config.d/*.conf includes, and cloud providers drop files there that silently override /etc/ssh/sshd_config. Always trust sshd -T over what you read in the main file.
Connection timeout
ssh: connect to host 203.0.113.10 port 22: Operation timed outPackets are being dropped silently. Causes, most to least likely:
- A firewall is blocking port 22 — UFW rule missing, or the cloud firewall does not allow your IP
- Wrong IP address — typo, or the server was rebuilt with a new IP
- Server is off or still booting — check the provider console
- Your network blocks outbound 22 — some corporate/hotel networks do; test with
nc -vz 203.0.113.10 22and try from a phone hotspot
# LOCAL — is the port reachable at all?
nc -vz 203.0.113.10 22
# Is the host reachable at all? (some providers block ICMP)
ping -c 3 203.0.113.10THE ESCAPE HATCH
Every serious provider offers a web console (Hetzner: "Console"; DigitalOcean: "Recovery Console") — a virtual monitor and keyboard attached directly to the VM, bypassing SSH and the network entirely. If you lock yourself out with a firewall rule, this is how you get back in. Find this button in your provider's UI now, before you need it.
Connection refused
ssh: connect to host 203.0.113.10 port 22: Connection refusedDifferent from a timeout: the packet arrived and the machine actively rejected it. So the network path is fine, and:
sshdis not running —sudo systemctl status sshvia the web consolesshdis on a different port — you changed it and forgot; tryssh -p 2222sshdfailed to start after a config edit — this is why you always runsudo sshd -tbefore restarting
# SERVER (via web console)
sudo systemctl status ssh
sudo ss -tulpn | grep sshd # what port is it actually listening on?
sudo sshd -t # validate config syntaxHost key warning
Covered above under known_hosts. Verify it was you who rebuilt the server, then ssh-keygen -R <host>.
"Too many authentication failures"
Your agent offered more than MaxAuthTries (default 6) keys before reaching the right one. Fix with IdentitiesOnly yes plus an explicit IdentityFile, or trim your agent with ssh-add -D.
Broken pipe / session freezes
Idle connections dropped by NAT. Add to ~/.ssh/config:
Host *
ServerAliveInterval 60
ServerAliveCountMax 3SURVIVE DISCONNECTS WITH tmux
Run long operations (migrations, big builds, apt upgrade) inside tmux on the server. If your connection drops, the process keeps running and you reattach with tmux attach. Losing a database migration halfway because your WiFi blinked is a genuinely bad afternoon.
# SERVER
sudo apt install -y tmux
tmux new -s deploy # start named session
# Ctrl+B then D to detach
tmux attach -t deploy # reattach later
tmux ls # list sessionsTransferring files
# LOCAL — copy a file to the server
scp ./backup.sql deploy@203.0.113.10:/tmp/
# Copy from server to laptop
scp deploy@203.0.113.10:/var/log/nginx/error.log ./
# Recursive directory copy
scp -r ./dist deploy@203.0.113.10:/var/www/app/
# Non-standard port (uppercase -P!)
scp -P 2222 ./file deploy@203.0.113.10:/tmp/For anything larger or repeated, use rsync — it transfers only differences and can resume:
# LOCAL
rsync -avz --progress ./dist/ deploy@203.0.113.10:/var/www/app/dist/| Flag | Meaning |
|---|---|
-a | Archive: recursive, preserve permissions, times, symlinks |
-v | Verbose |
-z | Compress during transfer |
--delete | Remove files on the destination that no longer exist locally (dangerous — check the path twice) |
TRAILING SLASHES IN RSYNC
rsync ./dist server:/var/www/app/ creates /var/www/app/dist/. rsync ./dist/ server:/var/www/app/ copies the contents into /var/www/app/. The trailing slash on the source changes the meaning.
Production Checklist — Level 1
- [ ] VPS created with Ubuntu 24.04 LTS, in a region near my users
- [ ] SSH public key added at creation time — no root password was ever emailed
- [ ] Ed25519 key pair generated, private key protected with a passphrase
- [ ] Key added to
ssh-agentso I type the passphrase once per session - [ ]
~/.ssh/configentry created withIdentitiesOnly yesand keepalives - [ ] Permissions verified:
700on~/.ssh,600on private key andauthorized_keys - [ ] Cloud firewall configured to allow only 22, 80, 443 inbound
- [ ] I have located my provider's web console and know how to use it
- [ ]
tmuxinstalled on the server for long-running operations - [ ] I can explain the difference between "connection refused" and "timed out" and what each implies
- [ ] Private key is backed up somewhere safe and offline; I know that losing it means console recovery