One small VPS (1 GB RAM is enough for several Node apps and static sites) that:
localhost onlyThe order is the point: everything is locked down before anything is installed. A public IP is scanned within minutes of booting.
| Placeholder | Meaning | Example |
|---|---|---|
<domain> |
the registrable domain | example.com |
<server> |
the SSH alias you will create on your laptop | web1 |
<server-ip> |
the VPS public IPv4 | 203.0.113.10 |
<deploy-user> |
the non-root login user | deploy |
<db-name> / <db-user> |
one database and role per app | app / app |
Platform assumed: Ubuntu 24.04 LTS, Node 22, PostgreSQL 16, Nginx 1.24, Cloudflare free plan. Each is a choice, not a requirement; the version-specific traps are called out where they apply.
Label every command with where it runs — laptop or server. Losing track of that is the most common source of confusion in this whole process.
Use Cloudflare Registrar: at-cost pricing, and registrar + DNS in one place means the nameservers are right immediately. For client work, register it in the client's own account — a domain in your account is a handover problem later.
Check:
dig NS <domain> +short # → two *.ns.cloudflare.com names
Generate it on the machine that will connect, with a passphrase. The private half never leaves the laptop.
ssh-keygen -t ed25519 -C "<server>"
cat ~/.ssh/id_ed25519.pub # paste this into the provider when creating the VPS
Check: ls -l ~/.ssh/id_ed25519* shows the private key as -rw-------.
| Setting | Value | Why |
|---|---|---|
| Region | nearest the users | cannot be changed later |
| Image | Ubuntu 24.04 LTS | supported for years |
| Size | smallest that fits (1 GB is fine to start) | RAM/CPU resize later; disk never shrinks |
| Auth | SSH key only | never a password |
| Automated backups | on | ~20% of the droplet price, rotates itself |
Say the monthly cost out loud before creating anything paid. Snapshots are billed per snapshot, per month, forever — take one only right before a risky change and delete it once the change is proven.
Verify the host key on the first connection instead of typing yes blindly:
ssh-keyscan -t ed25519 <server-ip> | ssh-keygen -lf - # compare with the provider's console
ssh root@<server-ip>
Check: you get a root shell on the server.
adduser <deploy-user>
usermod -aG sudo <deploy-user> # -aG APPENDS; plain -G replaces every group
mkdir -p /home/<deploy-user>/.ssh
cp /root/.ssh/authorized_keys /home/<deploy-user>/.ssh/
chown -R <deploy-user>:<deploy-user> /home/<deploy-user>/.ssh
chmod 700 /home/<deploy-user>/.ssh
chmod 600 /home/<deploy-user>/.ssh/authorized_keys
sshd silently ignores an authorized_keys others could modify — wrong permissions
give "Permission denied (publickey)" with no hint.
Check (laptop, a SECOND terminal — keep the root session open):
ssh <deploy-user>@<server-ip> 'sudo whoami' # → root
# ~/.ssh/config
Host <server>
HostName <server-ip> # the IP, not the domain — see the trap below
User <deploy-user>
IdentityFile ~/.ssh/id_ed25519
Once the domain is proxied by Cloudflare, ssh user@<domain> stops working: the proxy
carries HTTP/HTTPS only. Always SSH to the IP (via this alias).
Check: ssh <server> hostname prints the server's hostname.
Put settings in a drop-in with a low number. /etc/ssh/sshd_config begins with
Include /etc/ssh/sshd_config.d/*.conf, and sshd uses the first value it finds —
cloud images ship files there that silently override edits to the main file.
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
sudo sshd -t && sudo systemctl restart ssh
Restarting sshd does not drop existing sessions, which is what makes this safe.
Check (the effective config, not the file you edited):
sudo sshd -T | grep -iE "permitrootlogin|passwordauthentication"
# permitrootlogin no
# passwordauthentication no
ssh root@<server-ip> # from the laptop: must be refused
Allow SSH before enabling, or you lock yourself out instantly.
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
sudo ufw show added # confirm 22 is really there
sudo ufw enable
Add the provider's cloud firewall with the same three ports as a second layer — it
survives a bad ufw change and cannot be disabled from a compromised box.
Check: sudo ufw status lists exactly 22, 80, 443 (v4 and v6).
Without swap, npm install or a build on a 1 GB box gets OOM-killed.
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo echo x >> /etc/fstab would fail: the redirect is done by your shell, not sudo.
Use tee.
Check (proves fstab, not just the live swapon):
sudo swapoff /swapfile && sudo swapon -a && free -h # Swap: 2.0Gi
Usually on already. Confirm rather than assume.
cat /etc/apt/apt.conf.d/20auto-upgrades # both values "1"
systemctl is-active unattended-upgrades # active
If missing: sudo apt install unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades.
On a client server, do not add passwordless sudo. The password is a deliberate pause before destructive commands.
Check: both commands above print the expected values.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs postgresql postgresql-contrib nginx
Check: Postgres must listen on loopback only:
sudo ss -tlnp | grep 5432 # 127.0.0.1:5432 and [::1]:5432 — never 0.0.0.0
node -v # v22.x
Separate roles mean a bug in one app's queries cannot read another app's tables.
Generate the password; don't invent it (openssl rand -base64 24).
sudo -u postgres psql -c "CREATE USER <db-user> WITH PASSWORD '<generated>';"
sudo -u postgres psql -c "CREATE DATABASE <db-name> OWNER <db-user>;"
sudo -u postgres psql -d <db-name> -c "GRANT ALL ON SCHEMA public TO <db-user>; ALTER SCHEMA public OWNER TO <db-user>;"
The last line is the one everyone forgets: since Postgres 15, owning a database does
not let you create tables in public, and the first migration fails with
permission denied for schema public. Don't paste multi-line SQL containing \c —
psql's backslash commands eat the next line; use -d as above.
Check:
psql "postgresql://<db-user>:<generated>@127.0.0.1:5432/<db-name>" -c "create table t(x int); drop table t;"
Only if the server pulls from git. A deploy key is scoped to one repo; a personal access token grants everything the account can reach.
ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N "" -C "<server> deploy"
cat ~/.ssh/github_deploy.pub # add as a READ-ONLY deploy key on the repo
cat >> ~/.ssh/config <<'EOF'
Host github.com
IdentityFile ~/.ssh/github_deploy
IdentitiesOnly yes
EOF
No passphrase is right here (unattended pulls; read-only; useless off this box).
IdentitiesOnly yes stops SSH offering every key and being cut off for too many tries.
Better still: build on the laptop and rsync the output (see lean-production.md) —
then the server needs no repo access at all.
Check: ssh -T [email protected] greets you with the repo's name.
Cloudflare → SSL/TLS → Origin Server → Create Certificate, covering <domain> and
*.<domain>, 15 years. The wildcard means no future subdomain needs certificate work.
sudo mkdir -p /etc/ssl/cloudflare && sudo chmod 700 /etc/ssl/cloudflare
sudo nano /etc/ssl/cloudflare/<domain>.pem # paste the certificate
sudo nano /etc/ssl/cloudflare/<domain>.key # paste the private key
sudo chmod 644 /etc/ssl/cloudflare/<domain>.pem
sudo chmod 600 /etc/ssl/cloudflare/<domain>.key
Check — cert and key are a pair:
sudo openssl x509 -in /etc/ssl/cloudflare/<domain>.pem -noout -pubkey | sha256sum
sudo openssl pkey -in /etc/ssl/cloudflare/<domain>.key -pubout | sha256sum # identical
Create the shared pieces once; every site includes them. Full contents are in
skills/ship-a-site/references/nginx-blocks.md.
| File | Purpose |
|---|---|
/etc/nginx/snippets/security-headers.conf |
nosniff, frame options, referrer policy, HSTS (max-age=86400 to start) |
/etc/nginx/snippets/block-scanners.conf |
return 444 + access_log off for .env, .git, wp-*, xmlrpc.php |
/etc/nginx/conf.d/upgrade-map.conf |
the $connection_upgrade map (not built in) |
/etc/nginx/conf.d/cloudflare-realip.conf |
trust Cloudflare ranges, read CF-Connecting-IP |
/etc/nginx/conf.d/pwa-types.conf |
application/manifest+json webmanifest |
log_format vhost in nginx.conf |
put $host first so one log shows which site |
Remove the default site so an unknown host is not served your first site:
sudo rm -f /etc/nginx/sites-enabled/default
Nginx 1.24 (Ubuntu 24.04) wants listen 443 ssl http2; — http2 on; is 1.25+.
Check:
sudo nginx -t && sudo systemctl reload nginx
A records for @ and www → <server-ip>. Grey cloud (DNS only) while the
origin has no working HTTPS.ship-a-site.md) and confirm HTTPS against the origin:
bash
curl -sk --resolve <domain>:443:<server-ip> -o /dev/null -w '%{http_code}\n' https://<domain>/ # 200
(-k because origin certificates are trusted only by Cloudflare.)Proxying with Full (strict) before the origin has a certificate gives 5xx on every request and looks exactly like broken Nginx.
Check (from the laptop, the way a visitor arrives):
curl -s -o /dev/null -L -w "%{http_code} tls=%{ssl_verify_result} http/%{http_version}\n" https://<domain>/
# 200 tls=0 http/2
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://<domain>/
# 301 -> https://<domain>/
Before any real data lands: set up backups-and-restore.md. Before the first app:
read lean-production.md.
Check: backup-db.sh has run once and restore-db.sh reports backup is good.
sudo reboot
Check (laptop, after ~2 minutes): every site returns 200, ssh <server>
'systemctl --failed' lists nothing. A 502 in the first 20 s after boot is apps still
starting, not a failure.
| Symptom | Cause | Fix |
|---|---|---|
| Key auth fails with no explanation | ~/.ssh not 700 or authorized_keys not 600 |
fix modes and ownership |
PermitRootLogin no has no effect |
a file in sshd_config.d/ wins (first value found) |
use 10-*.conf; check sshd -T |
Locked out after ufw enable |
22 was not allowed first | provider console → ufw allow 22/tcp |
sudo echo x >> /etc/f → permission denied |
your shell does the redirect | echo x \| sudo tee -a /etc/f |
permission denied for schema public |
Postgres 15+ | GRANT ALL ON SCHEMA public + ALTER SCHEMA ... OWNER |
unknown directive "http2" |
Nginx 1.24 | listen 443 ssl http2; |
unknown "connection_upgrade" variable |
map missing | conf.d/upgrade-map.conf |
| SSH to the domain times out | Cloudflare proxies HTTP/HTTPS only | SSH to the IP via the alias |
| 5xx on every request after proxying | origin had no cert, mode is Full (strict) | grey cloud until origin HTTPS works |
| Browser cert warning on the IP | origin certs are trusted only by Cloudflare | expected — test through the domain |
| Every log line shows a Cloudflare IP | no real-IP config | cloudflare-realip.conf |
| Blocked paths return 520 via Cloudflare | 444 closed the connection |
expected |
| Thousands of 404s remain after blocking | most are addressed to the bare IP, long-tail paths | ignore; a static 404 costs nothing |
| Outgoing email never arrives | many providers block ports 25/465/587 by default | send via an HTTPS email API (see workflows/email-sending.md) |
More in reference/gotchas.md.
ssh root@<server-ip> is refused; ssh <server> works with the keysudo sshd -T shows permitrootlogin no, passwordauthentication noufw status and the cloud firewall allow only 22, 80, 443free -h shows swap, and it survives swapoff && swapon -a127.0.0.1/[::1] only; each app has its own DB and role<domain> and *.<domain>; SSL mode Full (strict)https://<domain> → 200 tls=0 http/2; http:// → 301/ and /index.html