Self-hosting a web app on a $6 droplet
Domain, server, database, TLS, backups and deploys — from nothing to live
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
- A domain you own, with DNS, TLS and DDoS protection
- An Ubuntu server only you can log into
- Postgres that the internet cannot reach
- Your app running as a system service that restarts itself
- HTTPS with a certificate you never have to renew
- Nightly database backups, stored in two places, with a tested restore
- Deploys as one command
- Total cost: about $8/month
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:
- Only two doors are open to the internet — 443 for the web, 22 for you.
- Everything else binds to
localhost, so it is not merely firewalled off, it is incapable of accepting an outside connection. - 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.
.devand.appforce HTTPS in every browser, so plain HTTP will not load during setup. That hides the exact layer you are trying to learn. Prefer.comfor 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
User=omar— not root. An exploited app gets an unprivileged account.EnvironmentFile=— systemd injects.envas real environment variables, so your start script needs no--env-fileflag.Restart=always— the crash protectionnohupnever gave you.
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;. Thehttp2 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:
- Reload Nginx, confirm HTTPS works against the droplet IP
- Switch DNS to proxied (orange cloud)
- 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:
- Verify every dump the moment it is written.
- Keep a copy off the machine. A backup on the same disk is not a backup.
- 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
- Test before you apply.
sshd -t,nginx -t,ufw show added. - Keep a second session open while changing how you log in.
- Verify the effective config, not the file you edited.
- Read the actual error —
journalctl -u service,/var/log/nginx/error.log— before proposing a fix. Do not stack a change on top of one that did not work. - Prove it, do not assume it. Kill the process to see it restart. Reboot to see it come back. Restore the backup to see that it restores.