From bare VPS to production in one sitting (without locking yourself out)
Sooner or later every developer does the math on their hosting bill and ends up staring at a $5 VPS. A bare Ubuntu box on Hetzner or DigitalOcean will happily run the app you're paying a platform 10× more to babysit — if you can get it from "root password in an email" to "hardened and serving traffic" without stepping on the classic rakes.
The setup itself isn't hard. It's maybe forty minutes if you know the order. The order is the whole trick, because two of the steps can lock you out of your own server if you do them backwards.
The two lockouts, up front
Lockout #1: enabling the firewall before allowing SSH. ufw enable with no rules cuts your connection and every future one. The rule must exist first:
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable # only now
Lockout #2: disabling password auth before proving your key works. Test the key login from a new terminal — while your existing session stays open as a lifeline — and only then turn passwords off. If either goes wrong anyway, your provider's out-of-band console is the way back in. Know where it is before you need it.
The sequence
1. A user that isn't root.
adduser deploy
usermod -aG sudo deploy
ssh-copy-id deploy@your.ip # from your laptop, ed25519 key
2. Harden sshd — after the key test from a second terminal:
PermitRootLogin no
PasswordAuthentication no
sshd -t to validate, systemctl reload ssh, test again. (We wrote a full SSH audit guide if you want the deeper version.)
3. Firewall, in the order above. Then fail2ban to ban the brute-force bots that will find you within the hour:
apt install -y fail2ban
# jail.local: maxretry = 3, bantime = 3600
4. Swap. The most skipped step, and the reason npm install mysteriously dies on 1GB boxes — the OOM killer eats the build:
fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
Swap doesn't fix undersized RAM for a busy app, but it turns "build crashes" into "build finishes slowly," which is the difference that matters at this price point.
5. Unattended security patches, so the box doesn't rot the moment you stop paying attention:
apt install -y unattended-upgrades
6. Runtime and process manager. For servers, install Node from NodeSource rather than nvm — you want one system-wide version that systemd and cron can find, not a shell function in one user's profile. For keeping the app alive, pm2 or a plain systemd unit both work; whichever you pick, the step people forget is persistence across reboots:
pm2 start app.js --name myapp
pm2 startup systemd -u deploy --hp /home/deploy
pm2 save
Skip pm2 save and your app is running right up until the first kernel update reboots the box at 4am.
7. nginx in front, TLS from Let's Encrypt:
apt install -y nginx certbot python3-certbot-nginx
certbot --nginx -d example.com
certbot renew --dry-run
Two certbot gotchas: port 80 must be open in both UFW and your cloud provider's firewall (the challenge silently times out otherwise), and failed attempts count against Let's Encrypt's rate limits — debug with --dry-run, not by re-running the real thing.
The snowflake problem
Here's what the tutorials don't mention: you'll do this again. A second box for staging, a bigger one when the app grows, a rebuild after you break something. And each one drifts a little — different Ubuntu point release, a step done slightly differently, that one fix you applied at midnight and never wrote down. Six months in you have four servers that are each almost identical, and a 200-line setup script you're afraid to run because it was written for the first box.
The traditional answers are configuration management (heavy for four boxes) or the script you're afraid of. There's a third one now.
This exact workflow — "set this box up for a Node app: hardened SSH, firewall, fail2ban, swap, pm2, nginx with TLS" — is a single ask in Tryssh. The copilot works through the sequence above over SSH, in the safe order, checking its work as it goes. Every state-changing step renders as the exact command with Run/Skip buttons, so you watch the server come together and nothing lands that you didn't see. The checks — did the key login work, is the firewall rule in place before enable, did sshd -t pass — run automatically, because reads are free.
$ sudo ufw default deny incoming Default incoming policy changed to 'deny' $ sudo ufw allow 22/tcp Rules updated Rules updated (v6) $ sudo ufw allow 80,443/tcp Rules updated Rules updated (v6) $ sudo ufw status added ufw allow 22/tcp ufw allow 80,443/tcp
You get the hosted-platform experience — describe what you want, get a working server — while still seeing, and approving, every command that made it. And when box number two comes along, it's the same sentence again.
Tryssh is a native macOS SSH workspace with an agentic copilot. Download it free — 200 agent turns to start, no card.