Platform-as-a-service is wonderful until the bill scales with your traffic or the free tier sleeps your app. Deploying to your own VPS costs a flat few pounds a month, runs whatever Node version you like, and nothing goes to sleep. This guide takes a Node app from a Git repo to a real domain with valid HTTPS, in about 20 minutes.

Using an AI coding agent? There's a ready-made prompt at the end of this guide. Copy that instead of this article.

What you'll need

  • A PrivateByte VPS. The Flare plan ($5.99/mo: 1 vCPU, 2 GB RAM, 25 GB SSD) runs a typical Node app comfortably.
  • A domain name you control the DNS for. HTTPS certificates are issued to hostnames, not IP addresses, so this one is genuinely required.
  • Your app in a Git repo, listening on a port from process.env.PORT.
  • About 20 minutes. Every command is copy-paste.

Step 1: Deploy your VPS

In the PrivateByte dashboard, open the store, choose Flare, pick Ubuntu 24.04, and deploy. Ready in under 60 seconds.

PrivateByte dashboard listing three servers, each showing a green Running indicator with its plan and Ubuntu 24.04
Your server appears in the dashboard within a minute, marked Running.

Step 2: Point your domain at the server

Do this first, because DNS takes time to propagate and everything after it depends on the name resolving.

In your DNS provider, create an A record pointing at your server's IP:

Type Name Value
A app your server's IP

That gives you app.yourdomain.com. Use @ instead of app if you want the bare domain.

Check it has taken effect before moving on:

dig +short app.yourdomain.com

It should print your server's IP. If it prints nothing, wait and try again; certificate issuance in Step 7 will fail while this is empty.

Step 3: Connect over SSH

ssh root@YOUR_SERVER_IP

Windows users: PowerShell has ssh built in, or use PuTTY. The dashboard also has a browser console if you'd rather install nothing.

Network and Access panel showing the server IP, root username, hidden password with a Reveal button, SSH port 22, and the full ssh command
Everything you need to connect is on the server page, including the exact ssh command.

Step 4: Create a non-root user

Your app runs code from every dependency in your lock file. None of that needs root:

adduser --disabled-password --gecos "" appuser
rsync --archive --chown=appuser:appuser ~/.ssh /home/appuser

That copies your SSH key across so you can log in as appuser later. Stay as root for now, because the next step installs system-wide packages and appuser deliberately has no sudo rights.

Step 5: Install Node.js, pm2 and Caddy

Still as root:

apt update && apt upgrade -y
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs git
npm install -g pm2

Then Caddy, which is what gives you HTTPS without touching a certificate by hand:

apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy

Step 6: Get your app running

Switch to appuser. Everything from here runs as that user:

su - appuser
git clone https://github.com/you/your-app.git
cd your-app
npm ci

npm ci rather than npm install: it installs exactly what your lock file says, which is what you want on a server. npm install is free to resolve newer versions and give you a build that differs from the one you tested.

If your app needs configuration, create a .env and lock it down:

nano .env
chmod 600 .env

Your app must listen on 127.0.0.1, not 0.0.0.0. In Express that means:

const port = process.env.PORT || 3000;
app.listen(port, '127.0.0.1', () => console.log(`listening on ${port}`));

This is the detail that makes the firewall setup in the next step actually true. An app bound to 0.0.0.0 is reachable from the internet on its own port whether or not you opened it in ufw, so your careful firewall rules protect nothing and your app is served over plain HTTP alongside the HTTPS you set up.

Now start it under pm2 and make it survive reboots:

pm2 start npm --name app -- start
pm2 save
pm2 startup

That last command prints another command to run. It needs root, so type exit to drop back to your root session, paste it there, then su - appuser again.

Step 7: Put Caddy in front of it

Back as root:

exit
nano /etc/caddy/Caddyfile

Replace the contents with three lines:

app.yourdomain.com {
    reverse_proxy 127.0.0.1:3000
}
systemctl reload caddy

Caddy requests a certificate from Let's Encrypt on reload, which takes a few seconds. There is no certbot step and no renewal cron, because Caddy renews automatically.

Step 8: Open the firewall

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

Order matters. Allow SSH first and enable the firewall last, or you'll lock yourself out.

Note what is not here: port 3000. Your app is reachable only through Caddy, which is the point of binding it to 127.0.0.1 in Step 6.

Verify it works

Start with the certificate, since that's what usually fails:

curl -sI https://app.yourdomain.com | head -1

You want HTTP/2 200. A certificate error here means Caddy couldn't complete the challenge, which is nearly always DNS.

Now the control most people skip. Confirm your app is not reachable directly:

curl -s --max-time 5 http://YOUR_SERVER_IP:3000 && echo "REACHABLE" || echo "correctly refused"

You want correctly refused. If it returns your app, it's bound to 0.0.0.0 and is being served unencrypted to anyone who knows the IP, regardless of your firewall rules.

Check pm2 and Caddy agree:

pm2 status
systemctl status caddy

Then prove the whole thing survives a restart:

reboot

Wait a minute, reload the site in a browser, and confirm the padlock is still there and the page still loads.

Troubleshooting

Certificate errors, or HTTPS never comes up. Caddy couldn't complete the Let's Encrypt challenge. Check dig +short app.yourdomain.com returns your IP, confirm ufw status includes 80 as well as 443, and read journalctl -u caddy -n 50. Port 80 must be open even though visitors use 443, because the challenge uses it.

502 Bad Gateway. Caddy is up but your app isn't answering. Check pm2 status shows online, and confirm the port in your Caddyfile matches the port your app actually listens on. A mismatch here is the most common cause.

The app works on the IP but not the domain. DNS hasn't propagated, or the A record points somewhere else. dig +short is the arbiter, not your browser, which caches aggressively.

Environment variables are missing after a restart. pm2 caches the environment from when the process was first started. After editing .env, run pm2 restart app --update-env, since a plain pm2 restart reuses the old values.

The app dies under load and pm2 keeps restarting it. Usually memory. Check free -m and pm2 logs app --err. A Node process with a leak will climb until the kernel kills it, and pm2 will faithfully restart it into the same wall.

Do it with an AI agent

If you'd rather hand this to Claude Code, Cursor, or another coding agent, don't paste the article at it. Articles are written for humans, and agents skim the warnings and lose the ordering. Copy this instead, and run it from your own machine with your agent able to SSH out.

Prompt for an AI agent
Deploy my Node.js app to a fresh Ubuntu 24.04 VPS, on a real domain with
automatic HTTPS.

FILL IN FIRST:
- SERVER_IP = <VPS IP from the PrivateByte dashboard>
- DOMAIN    = <hostname, e.g. app.example.com>
- REPO_URL  = <my app's git repo>
- APP_PORT  = <the port my app listens on, usually 3000>

STEPS:
1. SSH to root@SERVER_IP. Confirm Ubuntu 24.04 before changing anything.
2. Check DNS FIRST: "dig +short DOMAIN" must return SERVER_IP. If it does
   not, STOP and tell me. Everything downstream depends on it and finding out
   at the certificate step wastes the whole run.
3. Create user "appuser", copy my SSH key to it, and do all app work as that
   user. It has no password and no sudo, so run system installs as root first.
4. As root install Node.js 20 LTS from NodeSource, git, pm2 globally, and
   Caddy from the official Cloudsmith repo.
5. As appuser clone REPO_URL and run "npm ci", not "npm install", so the
   lock file is respected.
6. Confirm my app binds 127.0.0.1 and not 0.0.0.0. If it binds 0.0.0.0, STOP
   and tell me which line to change. Do not edit my source yourself. An app
   on 0.0.0.0 is served unencrypted on its own port regardless of the
   firewall.
7. Start it with pm2 as appuser, "pm2 save", then "pm2 startup" and run the
   command it prints as root.
8. Write /etc/caddy/Caddyfile with DOMAIN reverse_proxy to 127.0.0.1:APP_PORT,
   then reload caddy. Do not install certbot; Caddy handles certificates.
9. Firewall, in THIS EXACT ORDER:
       a) ufw allow OpenSSH
       b) ufw allow 80/tcp
       c) ufw allow 443/tcp
       d) ufw --force enable
   Do NOT open APP_PORT.

RULES:
- The step 9 ordering is not optional. Enabling the firewall before allowing
  OpenSSH ends my SSH session and locks me out of my own server.
- Port 80 must stay open even though visitors use 443. The certificate
  challenge uses it, and closing it breaks renewal silently, months later.
- Nothing destructive. If /etc/caddy/Caddyfile already has a site block, or
  the app directory exists, STOP and ask rather than overwriting.
- Do not edit my application code. If it will not start, show me the error.

VERIFY, SHOWING ME THE OUTPUT OF EACH:
- "dig +short DOMAIN"                        -> SERVER_IP
- "pm2 status"                               -> online, restarts 0
- "curl -sI https://DOMAIN | head -1"        -> HTTP/2 200, no cert warning
- "curl -s --max-time 5 http://SERVER_IP:APP_PORT" -> MUST refuse or time out.
  If it returns my app, the bind address is wrong and HTTPS is not enforced.
- "ufw status"                               -> OpenSSH, 80, 443, and NOT APP_PORT
- reboot, wait 60s, curl the HTTPS URL again -> still 200

Do not tell me a step succeeded without showing the output that proves it. If
a check fails, stop and report the real error. Do not retry silently or
improvise a workaround, especially around the firewall.

The negative check in that prompt is the one worth stealing. "The site loads over HTTPS" and "the app is only reachable over HTTPS" are different claims, and only the second one is what you actually wanted.

Deploy your VPS

A small Node app is an almost ideal VPS workload: always on, modest memory, and completely predictable in cost, which is the part platform hosting stops being once you have real traffic.

The Flare plan runs a typical app comfortably, and moving up a tier is a couple of clicks if you outgrow it.

Flare plan
$5.99/mo
1 vCPU · 2 GB RAM · 25 GB SSD
Deploy a Flare VPS
  • Unmetered bandwidth, no overage
  • Free DDoS protection
  • Daily automated backups
  • Browser console access
Ready in under 60 seconds. No contract, cancel any time.

Common questions

Do I need a domain name? For HTTPS, yes. Certificate authorities issue certificates to hostnames, not to IP addresses, so a bare IP can only ever serve plain HTTP. A domain is about ten pounds a year and removes an entire category of problems.

Caddy or Nginx? Both work. Caddy is used here because it obtains and renews certificates automatically with no extra tooling, which turns the fiddliest part of this guide into three lines of config. Nginx is more configurable and more widely documented, and needs certbot plus a renewal timer.

How do I deploy updates? git pull && npm ci && pm2 restart app from your project directory. If you changed environment variables, use pm2 restart app --update-env, because pm2 otherwise reuses the environment from when the process first started.

Can I run several apps on one server? Yes, and it's the main reason to do this. Give each app its own port and its own pm2 process name, then add one site block per domain to the Caddyfile. A 2 GB server handles several small Node apps without complaint.