oyware [email protected]

Self-hosting a web app on a $6 droplet

Domain, server, database, TLS, backups and deploys — from nothing to live

2026-09-04Ubuntu 24.04, Nginx, Node 22, PostgreSQL 16, Cloudflare, DigitalOcean

I am a mobile developer. Before the day this documents, I had never configured a web server. This is the whole path from owning no domain to running a real app with real users on hardware I control — including every mistake that cost time.

The setup is the point. The app is just something real to practise on.

What you end up with


The shape of it

        Cloudflare  ── DNS, TLS termination, proxy, DDoS
             │  HTTPS (origin certificate)
             ▼
   ┌─────────────────────────────────────────┐
   │  Ubuntu 24.04 droplet, 1 GB RAM         │
   │                                          │
   │  Nginx :443 ──proxy──▶ app :3001        │
   │                          │               │
   │                          ▼               │
   │              PostgreSQL, localhost only  │
   └─────────────────────────────────────────┘

Three ideas carry the whole design:

  1. Only two doors are open to the internet — 443 for the web, 22 for you.
  2. Everything else binds to localhost, so it is not merely firewalled off, it is incapable of accepting an outside connection.
  3. Nothing runs as root that does not have to.

Phase 1 — Domain

Register at Cloudflare Registrar. It sells at cost with no markup and no first-year-cheap-then-expensive trick, and because Cloudflare is both registrar and DNS host, the nameservers are correct the moment you buy.

Verify

dig NS yourdomain.com +short
# → indie.ns.cloudflare.com.
#   lennox.ns.cloudflare.com.

NS records say who is allowed to answer for your domain. That is all you own at this point — a name that resolves to nothing yet.

Choosing a name. Every 4-letter .com is registered (they ran out in 2007), and so is essentially every pronounceable 5- and 6-letter one. I checked ~450 candidates: real-word pairs are gone, invented words are gone. Budget for a compound name, or pay aftermarket prices in the thousands.

.dev and .app force HTTPS in every browser, so plain HTTP will not load during setup. That hides the exact layer you are trying to learn. Prefer .com for a first server.


Phase 2 — The server

Generate a key on your own machine first. The private half must never travel.

ssh-keygen -t ed25519 -C "my-droplet"

Use a passphrase. It encrypts the key on disk, so a stolen laptop does not hand over your server.

Create the droplet:

Setting Value Why
Region nearest to your users cannot be changed later
Image Ubuntu 24.04 LTS security updates until 2029
Size 1 GB / 1 vCPU / 25 GB RAM/CPU resize later; disk never shrinks
Auth SSH key, not password nothing to brute-force
Backups on ~20% of droplet cost, rotates itself

Verify the host key fingerprint on first connection instead of typing yes blindly. It is the only moment you can confirm the machine is yours.

ssh-keyscan -t ed25519 YOUR_IP | ssh-keygen -lf -

Phase 3 — Lock it down

Do this before installing anything. A public IP is scanned within minutes of booting.

A user that is not root

adduser omar
usermod -aG sudo omar

-aG appends to groups. Plain -G replaces every group the user has and will break the account. Always -aG.

Give that user your key

mkdir -p /home/omar/.ssh
cp /root/.ssh/authorized_keys /home/omar/.ssh/
chown -R omar:omar /home/omar/.ssh
chmod 700 /home/omar/.ssh
chmod 600 /home/omar/.ssh/authorized_keys

sshd silently ignores an authorized_keys file that other users could write to. Wrong permissions produce "Permission denied" with no explanation, and it is the most common cause of key auth mysteriously failing.

Test the new door before sealing the old one

Open a second terminal and confirm ssh omar@IP works. Keep the root session open until it does. This single habit prevents the classic lockout.

Disable root login

Ubuntu 24.04 has a trap here. /etc/ssh/sshd_config begins with:

Include /etc/ssh/sshd_config.d/*.conf

and sshd uses the first value it finds for each setting. Cloud images drop files in that directory, so editing the main file changes nothing. People then believe their server is hardened when it is not.

Put your own file in that directory with a low number so it wins:

sudo nano /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no

Check the effective config, not the file you edited:

sudo sshd -T | grep -iE "permitrootlogin|passwordauthentication"
sudo sshd -t          # syntax test — silence means success
sudo systemctl restart ssh

Restarting sshd does not drop existing sessions. Verify from a new terminal that omar still works and root is refused, then close the old one.

Firewall

Order matters. Enabling before allowing SSH locks you 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

Never open 5432. The database will listen on localhost only.

How to read a port scan from outside:

Result Meaning
connects something is listening
refused instantly firewall allows it, nothing listening
times out firewall silently dropped the packet

Silence is what you want for closed ports. A refusal tells a scanner the machine exists; a drop tells them nothing.

Swap

1 GB of RAM is not enough for npm install. Without swap the kernel kills the process and you get a bare Killed with no explanation.

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/file does not work. The >> redirection is performed by your shell, which is not root. tee is the program doing the writing, so tee is what needs sudo.

Test it survives a reboot without rebooting:

sudo swapoff /swapfile && sudo swapon -a && free -h

swapon -a re-reads /etc/fstab. If swap does not come back, the fstab line is wrong — and you have found out now instead of after a restart.


Phase 4 — Point the domain at the server

Add A records for @, www and any subdomains, all pointing at the droplet IP.

Leave the Cloudflare proxy OFF (grey cloud) until you have a certificate. Turning it on with SSL mode "Full (strict)" before the origin has a cert produces 5xx errors on every request, and it looks exactly like a broken Nginx when Nginx is fine. Grey cloud through phases 4–7, orange in phase 8.


Phase 5 — Runtime and database

Install Node from NodeSource (Ubuntu's own package is too old), then Postgres.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs postgresql postgresql-contrib

Confirm Postgres binds to localhost only:

ss -tlnp | grep 5432
# want: 127.0.0.1:5432   and   [::1]:5432
# NOT:  0.0.0.0:5432

Create the database

CREATE USER app WITH PASSWORD 'generated-not-invented';
CREATE DATABASE appdb OWNER app;
\c appdb
GRANT ALL ON SCHEMA public TO app;
ALTER SCHEMA public OWNER TO app;

The last two lines are the ones everyone misses. Since Postgres 15, owning a database no longer lets you create tables in its public schema. Skip them and your first migration fails with permission denied for schema public.

Also: do not paste multi-line SQL containing \c into psql. The backslash command swallows the following line and fails confusingly. Use psql -d dbname instead.

Private repo access

Use a deploy key — read-only, scoped to one repository:

ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N "" -C "server deploy"

No passphrase, because git pull runs unattended. That is acceptable because the key is read-only and useless off this machine. Add it in GitHub under the repo's Deploy Keys, leaving write access off.

Tell git to use it:

# ~/.ssh/config
Host github.com
  IdentityFile ~/.ssh/github_deploy
  IdentitiesOnly yes

IdentitiesOnly yes matters — otherwise SSH offers every key it can find and GitHub disconnects after too many failures.

Bind the app to localhost

If your app hardcodes 0.0.0.0, make it configurable:

const HOST = process.env.HOST ?? "0.0.0.0";
await app.listen({ port: PORT, host: HOST });

Then set HOST=127.0.0.1. Behind Nginx the app never needs a public interface, and this makes it incapable of accepting outside connections rather than relying on firewall rules staying correct forever.


Phase 6 — Run it as a service

# /etc/systemd/system/myapp.service
[Unit]
Description=My app
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service

[Service]
Type=simple
User=omar
WorkingDirectory=/home/omar/apps/myapp
EnvironmentFile=/home/omar/apps/myapp/.env
ExecStart=/usr/bin/npm run start
Restart=always
RestartSec=5
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp

Prove both claims rather than trusting them:

# crash test — the PID must change and the service stay active
sudo pkill -9 -f "[m]yapp"; sleep 8; systemctl status myapp

# boot test
sudo reboot

pkill -f "pattern" matches its own command line. Run it over SSH and it kills your session. Write the pattern as "[m]yapp" so it cannot match itself.


Phase 7 — Nginx

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/ssl/cloudflare/example.com.pem;
    ssl_certificate_key /etc/ssl/cloudflare/example.com.key;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Always test before reloading. Never reload blind.

sudo nginx -t && sudo systemctl reload nginx

A failed test changes nothing, so a mistake costs you a message rather than an outage.

Ubuntu 24.04 ships Nginx 1.24, which wants listen 443 ssl http2;. The http2 on; directive is 1.25+ and fails with unknown directive.

Nginx routes by the Host header, which is why one server hosts many sites. Adding another is a DNS record plus another server block on a different port.


Phase 8 — TLS

Generate a Cloudflare Origin Certificate covering example.com and *.example.com. The wildcard means every future subdomain is already covered.

sudo chmod 644 /etc/ssl/cloudflare/example.com.pem
sudo chmod 600 /etc/ssl/cloudflare/example.com.key   # root only

Verify the certificate and key are actually a pair before wondering why Nginx will not start:

sudo openssl x509 -in cert.pem -noout -pubkey | sha256sum
sudo openssl pkey -in cert.key -pubout      | sha256sum   # must match

Then, in this order:

  1. Reload Nginx, confirm HTTPS works against the droplet IP
  2. Switch DNS to proxied (orange cloud)
  3. Set SSL/TLS mode to Full (strict)

An Origin Certificate is trusted only by Cloudflare, not by browsers. Hitting the origin IP directly shows a certificate warning — that is correct, not a failure. Browsers see Cloudflare's own publicly-trusted certificate instead.

Full vs Full (strict). Both encrypt Cloudflare→origin. Only strict validates the origin's certificate. Cloudflare may default to plain "Full"; check what it actually says rather than assuming.

Add the real-IP config, or every log line shows a Cloudflare address:

# /etc/nginx/conf.d/cloudflare-realip.conf
set_real_ip_from 173.245.48.0/20;   # ... all ranges from
                                     # cloudflare.com/ips-v4 and -v6
real_ip_header CF-Connecting-IP;

Backups that are actually backups

Three rules:

  1. Verify every dump the moment it is written.
  2. Keep a copy off the machine. A backup on the same disk is not a backup.
  3. Restore one before you need to. Until you have, you have a hope, not a backup.
#!/usr/bin/env bash
set -uo pipefail
DB_URL=$(grep '^DATABASE_URL=' /path/.env | cut -d= -f2-)
DB_URL="${DB_URL%%\?*}"        # strip Prisma's ?schema=public — libpq rejects it
OUT="$HOME/backups/db-$(date -u +%Y%m%d-%H%M%S).dump"

pg_dump "$DB_URL" -Fc -f "$OUT" || { echo FAIL; exit 1; }
pg_restore --list "$OUT" >/dev/null || { rm -f "$OUT"; echo "CORRUPT"; exit 1; }
chmod 600 "$OUT"
find "$HOME/backups" -name 'db-*.dump' -mtime +7 -delete

Schedule it, then pull it somewhere else:

0 1 * * *  /home/omar/bin/backup-db.sh          # on the server
0 5 * * *  /Users/me/bin/pull-backups.sh        # on your laptop

Test the restore into a scratch database, then compare row counts against live and drop it. That is the whole exercise, and it takes five minutes.


Deploys in one command

The script must back up before it migrates. Reverting code is easy; reverting a database migration is not.

1. back up the database
2. record the current commit
3. git pull
4. install dependencies
5. prisma generate && migrate deploy
6. build
7. restart the service
8. health-check — and on failure, print the logs and the rollback command

Deliberately no auto-rollback. Rolling back code is safe; rolling back a migration is not, so the script hands you the command and the backup location instead of guessing.


What not to self-host

Host the things that are yours: your code, your data, your runtime.

Rent the things where someone else's scale genuinely beats you:

Service Why it stays rented
Email sending Deliverability is a decade of IP reputation, SPF/DKIM/DMARC and bounce handling. Mail from a fresh droplet IP goes to spam, and most providers block port 25 anyway.
LLM inference The models do not fit on your machine, and would not fit on one a hundred times bigger.
DNS / DDoS Cloudflare's free tier absorbs attacks you cannot.

The test: is this my product, or is it plumbing where someone else's scale makes them better than me? Hosting is plumbing you can now do yourself. Email reputation is not.

Every rented service is also a dependency someone else can change. Build a fallback for the ones that matter.


Costs

Item Monthly
Droplet, 1 GB $6.00
Automated weekly backups $1.20
Domain (.com, amortised) $0.87
Cloudflare — DNS, TLS, proxy, email routing $0.00
Total ≈ $8.07

Snapshots bill monthly, forever, per snapshot ($0.06/GB/mo on DigitalOcean) — separately from automated backups, which are one flat price for a rotating set. Take a snapshot before something risky, then delete it once the change is proven. A snapshot left behind is a subscription you forgot you started.


Gotchas, collected

The things that actually cost time.

Symptom Cause
Key auth fails silently ~/.ssh not 700, or authorized_keys not 600
PermitRootLogin no has no effect a file in sshd_config.d/ wins; first value found is used
sudo echo x >> /etc/f → permission denied the shell does the redirect, not sudo. Use tee
permission denied for schema public Postgres 15+ — GRANT ALL ON SCHEMA public needed
invalid URI query parameter: "schema" Prisma's ?schema=public; libpq rejects it. Strip with ${URL%%\?*}
unsupported version (1.x) in file header dump written by a newer pg_dump than your pg_restore
SET transaction_timeout fails on restore PG17+ dump into PG16. Strip that line
unknown directive "http2" Nginx 1.24 wants listen 443 ssl http2;
Login "does nothing", no error session cookie is Secure; browsers discard it over plain HTTP
SSH to your domain times out after enabling Cloudflare the proxy carries HTTP/HTTPS only. SSH to the IP
Browser shows cert error long after DNS is fixed OS and browser DNS caches. Flush both
pkill -f "pattern" kills your own SSH session the pattern matches its own command line. Use "[p]attern"
.env values appear missing leading whitespace from pasting; keys become " KEY"
Row counts differ slightly pg_stat_user_tables is an estimate. Use count(*)
Deleted cloud resource still answers teardown is asynchronous. Re-check after a minute
GitHub Actions cron runs hours late scheduled runs are best-effort — observed 43 min to 12 h late

The habits worth keeping