Skip to content

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.

ProviderRegions relevant to Central Asia / Europe
HetznerNuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore
DigitalOceanFrankfurt, Amsterdam, London, Bangalore, Singapore
VultrFrankfurt, 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.

PlanGood for
2 vCPU / 4 GB / 40 GBA Nuxt + NestJS + PostgreSQL + Redis stack in early production. This is the realistic starting point.
1 vCPU / 2 GB / 20 GBStaging, or a single small app. Tight once you add a build step.
4 vCPU / 8 GB / 80 GBReal 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 build for 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:

DirectionPortSource
Inbound22 (TCP)Your IP, or 0.0.0.0/0 if your IP is dynamic
Inbound80 (TCP)0.0.0.0/0
Inbound443 (TCP)0.0.0.0/0
Outboundallallow

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:

FileNameWhere it livesShare it?
Private keyid_ed25519Your laptop, ~/.ssh/, mode 600Never
Public keyid_ed25519.pubCopied to servers, authorized_keysFreely

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 ​

PasswordSSH key
What is transmittedThe password itself (encrypted in transit)Only a signature
Brute-forceableYes — bots try thousands per hourNo — 2^128 search space
PhishableYesNo
Reused across servicesUsually, by humansNever
Works unattended (CI/CD)BadlyYes
Server compromise leaks itYesNo

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.

bash
# LOCAL
ssh-keygen -t ed25519 -C "aziz@laptop-2026" -f ~/.ssh/id_ed25519
FlagMeaning
-t ed25519Key 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_ed25519Output 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 ​

Ed25519RSA
Key size256-bit3072 or 4096-bit needed
Public key lengthOne short lineMultiple wrapped lines
SpeedMuch fasterSlower
Security~128-bit equivalentComparable at 3072+
SupportOpenSSH 6.5+ (2014)Universal
Vulnerable to bad randomnessResistant by designYes

Use Ed25519. Only fall back to RSA if you hit ancient hardware or a legacy system:

bash
# 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 ​

bash
# LOCAL
cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8x2vQ... aziz@laptop-2026

Three fields: algorithm, base64 key material, comment.

bash
# LOCAL — show the fingerprint (a short hash used to identify the key)
ssh-keygen -lf ~/.ssh/id_ed25519.pub
256 SHA256:9Xk2vQ8mN... aziz@laptop-2026 (ED25519)

How to copy a public key to a server ​

Method 1 — ssh-copy-id (easiest) ​

bash
# LOCAL
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@203.0.113.10

This 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) ​

bash
# 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 ​

bash
# LOCAL
ssh root@203.0.113.10

Breaking it down:

PartMeaning
sshThe client program
rootThe remote username to log in as
@Separator
203.0.113.10Server 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 ​

bash
# 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 ​

bash
# 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 ​

bash
# 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:

sshconfig
# ~/.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 yes

Now ssh prod is enough. And so is scp file.tar.gz prod:/tmp/.

OptionWhy
IdentitiesOnly yesOffer 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 60Send a keepalive every 60s so idle sessions are not dropped by NAT/firewall timeouts.
ServerAliveCountMax 3Give up after 3 unanswered keepalives (3 min).
bash
# LOCAL
chmod 600 ~/.ssh/config

~/.ssh — the directory that controls everything ​

On both machines:

PathPurposeRequired mode
~/.ssh/The directory itself700 (drwx------)
~/.ssh/id_ed25519Your private key600 (-rw-------)
~/.ssh/id_ed25519.pubYour public key644
~/.ssh/authorized_keysPublic keys allowed to log in as this user600
~/.ssh/known_hostsServer identities you have accepted644
~/.ssh/configClient 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:

bash
# SERVER
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R $USER:$USER ~/.ssh

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

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

command="..." 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:

bash
# LOCAL
ssh-keygen -R 203.0.113.10

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

bash
# 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 -D

On macOS, the keychain integrates with the agent. Add to ~/.ssh/config:

sshconfig
Host *
    UseKeychain yes
    AddKeysToAgent yes

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

bash
# LOCAL
ssh -v deploy@203.0.113.10

Lines that matter in the output:

Output lineMeans
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: publickeyPassword auth is disabled server-side
Offering public key: /Users/aziz/.ssh/id_ed25519Client is trying this key
Server accepts keyThe key is in authorized_keys
Authenticated to 203.0.113.10Success

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:

bash
# SERVER
sudo journalctl -u ssh -f

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

#CheckCommand
1Are you using the right username?ssh -v shows it. root vs deploy vs ubuntu varies by provider.
2Is the right key being offered?Look for Offering public key: in -v output
3Is the key in authorized_keys?ssh-keygen -lf ~/.ssh/id_ed25519.pub locally, compare fingerprint to server-side ssh-keygen -lf ~/.ssh/authorized_keys
4Are permissions right on the server?ls -la ~/ ~/.ssh/ — need 755 home, 700 .ssh, 600 authorized_keys
5Is authorized_keys owned by the right user?chown -R deploy:deploy /home/deploy/.ssh
6Is the key loaded in the agent?ssh-add -l
7Too many keys offered?Add IdentitiesOnly yes
8Is PubkeyAuthentication yes in sshd config?sudo sshd -T | grep -i pubkey
9Is 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 out

Packets are being dropped silently. Causes, most to least likely:

  1. A firewall is blocking port 22 — UFW rule missing, or the cloud firewall does not allow your IP
  2. Wrong IP address — typo, or the server was rebuilt with a new IP
  3. Server is off or still booting — check the provider console
  4. Your network blocks outbound 22 — some corporate/hotel networks do; test with nc -vz 203.0.113.10 22 and try from a phone hotspot
bash
# 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.10

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

Different from a timeout: the packet arrived and the machine actively rejected it. So the network path is fine, and:

  1. sshd is not running — sudo systemctl status ssh via the web console
  2. sshd is on a different port — you changed it and forgot; try ssh -p 2222
  3. sshd failed to start after a config edit — this is why you always run sudo sshd -t before restarting
bash
# 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 syntax

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

sshconfig
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3

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

bash
# 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 sessions

Transferring files ​

bash
# 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:

bash
# LOCAL
rsync -avz --progress ./dist/ deploy@203.0.113.10:/var/www/app/dist/
FlagMeaning
-aArchive: recursive, preserve permissions, times, symlinks
-vVerbose
-zCompress during transfer
--deleteRemove 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-agent so I type the passphrase once per session
  • [ ] ~/.ssh/config entry created with IdentitiesOnly yes and keepalives
  • [ ] Permissions verified: 700 on ~/.ssh, 600 on private key and authorized_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
  • [ ] tmux installed 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

Next: Level 2 — Linux Basics for Developers →