Putting a small site on a VPS takes about the same twenty minutes as a managed host's dashboard, once Caddy is installed. What decides whether those twenty minutes are worth starting is a question a managed host never makes you answer: can anything on the public internet actually reach port 443 on your machine.
What "a small website" actually means here
This is for a static site, a blog built by a generator, or a light PHP or Node app — the kind of thing a single process and a few hundred megabytes of RAM handles without strain. It is not a guide to running a cluster or a site built to survive a front-page traffic spike; a 1 GiB VPS is the wrong machine for that regardless of configuration.
The part easy to miss coming from a managed host: a website is not a bot or a scheduled job. A Discord bot or a cron-triggered scraper only ever makes outbound connections, so it runs happily behind almost any network setup. A website's job is the opposite — a stranger's browser makes an inbound connection to you, on port 80 or 443, at a hostname that resolves to your address.
Read this before you buy: the NAT IPv4 catch
Budget VPS plans commonly hand out NAT IPv4 — one shared public address with a small, fixed list of forwarded ports — rather than an address that is yours alone. For a lot of workloads that is invisible. For a website it is not: a browser typing https://example.com always asks for port 443 on whatever address that hostname resolves to, and there is no way to tell it to use port 41022 instead. Unlike a VPN or a bot's control panel, a website's port is not negotiable.
Two honest ways through it:
- Get a dedicated IPv4. With an address of your own, 80 and 443 belong to you and Caddy's automatic HTTPS works exactly as documented. On our plans that is available on request by e-mail, not as a self-service add-on.
-
Use Cloudflare Tunnel.
cloudflaredruns on your VPS and opens an outbound connection to Cloudflare's network, which routes visitor traffic back down that same connection. No inbound port is involved, so NAT is irrelevant — Cloudflare's edge holds the public IP and the certificate.
The dedicated IPv4 route is plainer if you already have it; Cloudflare Tunnel is the honest answer if you do not, at no extra cost. Read NAT IPv4 vs a dedicated IP and NAT IPv4, ports and forwarding before you decide which situation you are in.
Step 1: a clean machine
Deploy an Ubuntu 24.04 LTS VPS and do the ordinary hardening before anything else touches it: a non-root user with your SSH key on it, password authentication turned off, and a firewall that defaults to deny with only SSH, 80 and 443 allowed through. connect to your VPS over SSH walks through the connection itself.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Going the dedicated-IPv4 route, point an A record (and AAAA if you have IPv6) at that address now and let DNS settle before Caddy fetches a certificate. Going the Cloudflare Tunnel route, skip this — DNS gets created for you when you route the tunnel in Step 5.
Step 2: get your files onto the box
Pick a directory outside your home folder so permissions are predictable, and put a dedicated system user on it rather than deploying as root:
sudo mkdir -p /var/www/example.com
sudo useradd --system --no-create-home --shell /usr/sbin/nologin deploy
sudo chown -R deploy:deploy /var/www/example.com
sudo usermod -aG deploy youruser
sudo chmod -R 2775 /var/www/example.com
chown alone leaves the directory at its default 755, so deploy owns it but youruser can't write there yet — the rsync below would fail. The group-add and 2775 (setgid) fix that; a fresh SSH connection, which rsync always opens, picks it up immediately.
For a static site, rsync the built output straight from your machine:
rsync -avz --delete ./dist/ youruser@vps-address:/var/www/example.com/
For anything with a build step, git clone the repository onto the VPS and build there, or build locally and rsync only the output — pick one path and stay consistent.
Step 3: automatic HTTPS with Caddy
Install Caddy from its own repository rather than an outdated distro package:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
For a pure static site, the whole configuration is this, in /etc/caddy/Caddyfile:
example.com {
root * /var/www/example.com
file_server
encode gzip
}
Reload it and Caddy takes care of the certificate on its own, with no certbot step and no renewal cron job:
sudo systemctl reload caddy
The one condition that has to hold: something on the public internet must be able to complete an HTTP-01 or TLS-ALPN challenge against your address on 80 or 443. A dedicated IPv4 satisfies that outright. Behind NAT IPv4 with no forwarded 80/443, Caddy simply cannot get a certificate this way — which Step 5 covers, as a different, simpler HTTPS story, not a broken one. More in Caddy's automatic HTTPS documentation.
Step 4: a systemd-managed app behind Caddy
A static site needs nothing beyond file_server. A light PHP or Node app needs its own process, kept alive by systemd rather than a terminal session that dies on disconnect.
For PHP, install php-fpm and point Caddy at its socket:
sudo apt install -y php-fpm
example.com {
root * /var/www/example.com
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
Match the socket to your installed PHP version, not this example — check with php -v and adjust php8.3-fpm.sock on a newer release.
For Node, install an LTS release and run the app under its own unit rather than pm2 or a screen session:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
[Unit]
Description=example.com app
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/example.com
ExecStart=/usr/bin/node server.js
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now example-app.service
example.com {
reverse_proxy 127.0.0.1:3000
}
Bind the app to 127.0.0.1 and let Caddy be the only thing the internet talks to directly: one process handles TLS, and the app itself is never exposed to a port scanner. systemctl status example-app and journalctl -u example-app -f are where to look first when the site is up but the app is not answering.
Step 5: Cloudflare Tunnel, if you have no inbound port at all
On NAT IPv4 with no forwarded 80/443, cloudflared gets you a real HTTPS site anyway — it never asks your VPS to accept an inbound connection:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update
sudo apt install -y cloudflared
sudo cloudflared tunnel login
sudo cloudflared tunnel create example-site
sudo cloudflared tunnel route dns example-site example.com
Run all three with sudo — unprivileged, tunnel login and tunnel create write cert.pem and the credentials JSON to your own home directory, not /root/.cloudflared, where the root-run service below expects them. route dns creates the CNAME inside Cloudflare — no DNS panel needed. Save the config as /root/.cloudflared/config.yml, pointing it at whatever is listening locally:
tunnel: <TUNNEL-ID>
credentials-file: /root/.cloudflared/<TUNNEL-ID>.json
ingress:
- hostname: example.com
service: http://localhost:80
- service: http_status:404
sudo cloudflared service install
sudo systemctl enable --now cloudflared
Two things change behind a tunnel. Caddy no longer needs its own certificate — Cloudflare's edge holds it and reaches cloudflared over its own encrypted connection, so a plain :80 block is correct, not a downgrade:
:80 {
root * /var/www/example.com
file_server
}
And your firewall needs no inbound 80 or 443 at all — the tunnel is entirely outbound, the honest answer for a small site not paying for a dedicated address. Full mechanics are in Cloudflare's tunnel documentation.
Step 6: backing up the site directory
The site directory and its running configuration are what need to survive the machine disappearing — not the OS itself, which a clean image replaces from scratch.
sudo mkdir -p /root/backups
sudo tar czf /root/backups/site-$(date +%F).tar.gz \
-C / var/www/example.com etc/caddy etc/systemd/system/example-app.service
/root is mode 700, so both commands need sudo — and mkdir runs first because /root/backups doesn't exist yet on a machine that has never been backed up.
A tarball on the same disk it was made from is not a backup, it is a file that vanishes with the disk. Copy it off with rsync or scp on a cron entry or systemd timer, and treat that copy, not the tarball, as what you rely on. back up your VPS covers what is and is not included by default if you would rather not run this yourself.
If your app has its own data — uploads, a SQLite file, anything a visitor created — back that up specifically rather than assuming it lives inside the tree you already tar up.
What a 1 GiB machine comfortably serves
Static files are close to free: Caddy's own footprint is a few tens of megabytes resident, and serving a page is mostly the kernel handing bytes off disk cache to a socket. A static site on 1 GiB is bounded by your network link long before RAM, at almost any traffic a small site sees.
A light PHP-FPM pool with a handful of workers, or a single Node process with a modest connection pool, typically sits in the tens of megabytes once warmed up — comfortable room on 1 GiB alongside the OS, sshd and Caddy. It stops being comfortable once you add a database on the same box, load large files into memory per request, or run more than one app — any of those is a reason to size up, not tune around.
Two cheap habits worth doing on day one: add a swapfile (a gigabyte is plenty) so a short burst does not get a process killed outright, and watch disk — the Starter tier's 25 GB holds a small site and a couple weeks of backups without effort, but logs and an unpruned upload directory quietly fill it.
On overnight.host
Full disclosure: this is what we sell. A 1 GiB Starter serves a static site or a light PHP/Node app without noticing; move up a tier once you add a database alongside it or the traffic is no longer small.
Linux KVM VPS — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order vps-starter → · Linux KVM VPS overview
FAQ
Do I need a dedicated IPv4 to host a website on a VPS?
Only if you want Caddy to get its own certificate directly, which needs 80 or 443 reachable. Without those ports forwarded, Cloudflare Tunnel gets you a real HTTPS site with no inbound port at all, at no extra cost.
Is Cloudflare Tunnel a real substitute for a dedicated IP, or a workaround?
A genuinely different, valid architecture: your origin never needs a public IP for this hostname, because cloudflared makes an outbound-only connection and Cloudflare's edge handles the inbound side. The trade-off is that Cloudflare sits in the request path and holds the certificate — if avoiding that matters, a dedicated IPv4 is the alternative.
Will Caddy really renew my certificate with no extra setup?
Yes, as long as your domain resolves to an address your VPS owns and 80 or 443 is reachable for the ACME challenge. There is no certbot timer to maintain; Caddy renews automatically well before expiry.
How much traffic can a 1 GiB VPS actually handle?
A static site is limited by your network connection long before RAM. A small PHP or Node app is comfortable with a handful of concurrent requests without tuning; heavy sustained concurrency or a database on the same box are reasons to size up.
What should I actually back up?
The site directory, your Caddy configuration, and any systemd unit for your app — plus the app's own data directory if it stores anything a visitor created. Copy it off the VPS on a schedule; a tarball that never leaves the same disk is not a backup.
Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.
Originally published at overnight.host — the canonical, kept-current version of this guide.
Top comments (0)