--- title: New server — from nothing to a hardened Ubuntu droplet summary: Domain, VPS, locked-down SSH, firewall, swap, Postgres on loopback, Nginx behind Cloudflare with an origin certificate, security headers and scanner filtering. when_to_use: You need a fresh server for your own apps or for a client, or you are replacing one. Not for adding a site to a server that already exists — use ship-a-site.md. prerequisites: A payment method at a VPS provider (DigitalOcean, Hetzner, etc.), a Cloudflare account (free plan), a terminal with ssh, about two hours. related: [workflows/ship-a-site.md, workflows/lean-production.md, workflows/backups-and-restore.md, reference/gotchas.md] last_verified: 2026-09-26 --- # New server — from nothing to a hardened Ubuntu droplet ## Goal One small VPS (1 GB RAM is enough for several Node apps and static sites) that: - accepts SSH by key only, as a non-root user, with root login off - exposes only ports 22, 80 and 443 - runs Postgres reachable from `localhost` only - serves every site through Nginx with TLS, behind Cloudflare's proxy - sends security headers on every path and drops scanner traffic silently - survives a reboot with everything coming back on its own **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 | |---|---|---| | `` | the registrable domain | `example.com` | | `` | the SSH alias you will create on your laptop | `web1` | | `` | the VPS public IPv4 | `203.0.113.10` | | `` | the non-root login user | `deploy` | | `` / `` | 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:** ```bash dig NS +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. ```bash ssh-keygen -t ed25519 -C "" 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: ```bash ssh-keyscan -t ed25519 | ssh-keygen -lf - # compare with the provider's console ssh root@ ``` **Check:** you get a root shell on the server. ### 4. Create the non-root user (server, as root) ```bash adduser usermod -aG sudo # -aG APPENDS; plain -G replaces every group mkdir -p /home//.ssh cp /root/.ssh/authorized_keys /home//.ssh/ chown -R : /home//.ssh chmod 700 /home//.ssh chmod 600 /home//.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):** ```bash ssh @ 'sudo whoami' # → root ``` ### 5. Add an SSH alias (laptop) ```sshconfig # ~/.ssh/config Host HostName # the IP, not the domain — see the trap below User IdentityFile ~/.ssh/id_ed25519 ``` Once the domain is proxied by Cloudflare, `ssh user@` stops working: the proxy carries HTTP/HTTPS only. Always SSH to the IP (via this alias). **Check:** `ssh 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. ```bash 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):** ```bash sudo sshd -T | grep -iE "permitrootlogin|passwordauthentication" # permitrootlogin no # passwordauthentication no ssh root@ # from the laptop: must be refused ``` ### 7. Firewall (server) Allow SSH **before** enabling, or you lock yourself out instantly. ```bash 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. ```bash 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):** ```bash sudo swapoff /swapfile && sudo swapon -a && free -h # Swap: 2.0Gi ``` ### 9. Unattended security upgrades (server) Usually on already. Confirm rather than assume. ```bash 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) ```bash 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: ```bash 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`). ```bash sudo -u postgres psql -c "CREATE USER WITH PASSWORD '';" sudo -u postgres psql -c "CREATE DATABASE OWNER ;" sudo -u postgres psql -d -c "GRANT ALL ON SCHEMA public TO ; ALTER SCHEMA public OWNER TO ;" ``` 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:** ```bash psql "postgresql://:@127.0.0.1:5432/" -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. ```bash ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N "" -C " 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 git@github.com` greets you with the repo's name. ### 13. Cloudflare origin certificate (Cloudflare dashboard + server) Cloudflare → SSL/TLS → Origin Server → Create Certificate, covering **`` and `*.`**, 15 years. The wildcard means no future subdomain needs certificate work. ```bash sudo mkdir -p /etc/ssl/cloudflare && sudo chmod 700 /etc/ssl/cloudflare sudo nano /etc/ssl/cloudflare/.pem # paste the certificate sudo nano /etc/ssl/cloudflare/.key # paste the private key sudo chmod 644 /etc/ssl/cloudflare/.pem sudo chmod 600 /etc/ssl/cloudflare/.key ``` **Check — cert and key are a pair:** ```bash sudo openssl x509 -in /etc/ssl/cloudflare/.pem -noout -pubkey | sha256sum sudo openssl pkey -in /etc/ssl/cloudflare/.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: ```bash 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:** ```bash sudo nginx -t && sudo systemctl reload nginx ``` ### 15. DNS and proxy, in this order (Cloudflare dashboard) 1. `A` records for `@` and `www` → ``. **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 :443: -o /dev/null -w '%{http_code}\n' https:/// # 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):** ```bash curl -s -o /dev/null -L -w "%{http_code} tls=%{ssl_verify_result} http/%{http_version}\n" https:/// # 200 tls=0 http/2 curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http:/// # 301 -> https:/// ``` ### 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) ```bash sudo reboot ``` **Check (laptop, after ~2 minutes):** every site returns 200, `ssh '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 - [ ] `ssh root@` is refused; `ssh ` works with the key - [ ] `sudo sshd -T` shows `permitrootlogin no`, `passwordauthentication no` - [ ] `ufw status` and the cloud firewall allow only 22, 80, 443 - [ ] `free -h` shows swap, and it survives `swapoff && swapon -a` - [ ] unattended-upgrades is active - [ ] Postgres listens on `127.0.0.1`/`[::1]` only; each app has its own DB and role - [ ] origin certificate covers `` and `*.`; SSL mode Full (strict) - [ ] `https://` → `200 tls=0 http/2`; `http://` → 301 - [ ] security headers present on `/` **and** `/index.html` - [ ] a reboot brings everything back without touching it - [ ] a nightly backup exists and one restore drill has passed