kitdocs · github

New server — from nothing to a hardened Ubuntu droplet

raw markdown workflows/new-server.md verified 2026-09-26

Goal

One small VPS (1 GB RAM is enough for several Node apps and static sites) that:

The order is the point: everything is locked down before anything is installed. A public IP is scanned within minutes of booting.

Inputs (placeholders used)

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.

Steps

1. Register the domain (laptop, browser)

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

2. Create an SSH key (laptop)

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

3. Create the VPS (provider dashboard)

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.

4. Create the non-root user (server, as root)

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

5. Add an SSH alias (laptop)

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

6. Disable root login and passwords (server)

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

7. Firewall (server)

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

8. Swap (server)

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

9. Unattended security upgrades (server)

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.

10. Node and Postgres (server)

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

11. One database and role per app (server)

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;"

12. Read-only deploy key for private repos (server, optional)

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.

13. Cloudflare origin certificate (Cloudflare dashboard + server)

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

14. Nginx baseline (server)

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

15. DNS and proxy, in this order (Cloudflare dashboard)

  1. A records for @ and www → <server-ip>. Grey cloud (DNS only) while the origin has no working HTTPS.
  2. Add the first site (see 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.)
  3. Switch the records to proxied (orange).
  4. SSL/TLS mode → Full (strict). Read what it currently says; it is often "Full" or "Automatic". Strict is the only mode that validates the origin certificate.

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>/

16. Backups and deploys

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.

17. Reboot test (server)

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.

Traps

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.

Done when