5N3BLOG ← 5N3

Four sites, one VPS: how fiventhree.com actually got deployed

The whole domain went live on a single server in one evening. Here is the topology, the order I did things in, and the four things that broke, including the one where three sites returned 404 while the fourth worked perfectly.

Project
The 5N3 Domain
Difficulty
Intermediate
Reading time
11 min
Published
Last updated
Never revised

For about six weeks, fiventhree.com existed only on my laptop. Four sites, a design system, a real application with a database, all of it running on localhost and none of it reachable by anyone but me.

On 30 July 2026 it went live on a single VPS. This is what that involved: the shape of the thing, the order I did it in, and the four separate ways it broke along the way. The failures are the useful part, so they get their own section rather than being quietly left out.

What's actually running#

Four hostnames, one server:

HostnameWhat it isHow it's served
fiventhree.comLanding pageStatic files
blog.fiventhree.comThis knowledge baseStatic files
profile.fiventhree.comPortfolioStatic files
tracker.fiventhree.comThe Tracker appReverse proxy to containers

Three of those are plain HTML and CSS. The fourth is a Next.js frontend talking to a FastAPI backend talking to Postgres. That split matters more than it looks, because it's the reason one specific thing broke later.

The topology#

flowchart TD
  U["Visitor"] --> CF["Cloudflare DNS<br/>grey cloud, DNS only, not proxied"]

  subgraph VPS["One VPS, firewall allows 22, 80 and 443 only"]
    CADDY["Caddy<br/>terminates HTTPS for all four hostnames"]
    STATIC["Static files on the host<br/>bind-mounted read-only"]
    FE["Next.js frontend"]
    BE["FastAPI backend"]
    DB[("Postgres<br/>publishes no port at all")]
  end

  CF --> CADDY
  CADDY -->|"three static hostnames"| STATIC
  CADDY -->|"tracker.fiventhree.com"| FE
  CADDY -->|"tracker.fiventhree.com/api/*"| BE
  BE --> DB

Everything arrives at Caddy. Caddy is a web server that does two jobs here: it serves files straight off disk for the three static sites, and it forwards requests to the application containers for the tracker. It also gets HTTPS certificates automatically, which removes what used to be the most annoying part of putting anything on the internet.

The important detail is at the bottom of that diagram. Postgres publishes no port. It is reachable by the backend container and by nothing else. Not from the internet, and not even from the server's own network interfaces. Several other decisions depend on that being true, and I'll come back to why.

Step 1: DNS, before touching the server#

DNS is the slowest thing to change and the most common reason a first deploy fails, so it goes first.

Rather than clicking five records into a dashboard by hand, I wrote a standard BIND zone file and imported it. Five A records (the apex plus www, blog, profile and tracker), all pointing at the same server, because Caddy decides what to serve based on the hostname the browser asked for.

The file also declares that this domain sends no email at all: a null MX record, an SPF record authorising nobody, and a DMARC policy of reject. That's free anti-spoofing. Without it, anyone can forge mail claiming to be from my domain and a recruiter's mail server has no way to tell. Worth having on a domain that's going on a CV.

Warning

Cloudflare's import screen has a tickbox: "Proxy imported DNS records." Untick it. Leaving it on routes traffic through Cloudflare's edge, which breaks this setup twice. Certificates won't issue, because Caddy needs an unproxied path to complete the Let's Encrypt challenge. And every request would then arrive from a Cloudflare address, so the tracker's login rate limiter would bucket the entire internet into one counter, meaning a stranger could exhaust my five-attempts-per-minute budget and lock me out of my own app.

All records stay grey-cloud, DNS only, on purpose.

Step 2: harden the server before it holds anything#

A fresh VPS is a machine with a public IP address that people are already scanning. Three things, in this order:

SSH keys instead of passwords. Generate a key pair, copy the public half up, then turn password authentication off entirely. A password can be guessed; a key can't be, in any practical sense.

Root login disabled. Administrative work happens through a normal user with sudo. It means an attacker has to guess a username as well, and it leaves an audit trail of who did what.

A firewall that denies by default. UFW allowing exactly three ports: 22 for SSH, 80 and 443 for the web. Everything else is refused, including the database port, twice over.

terminal
$ sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing)

That "deny incoming" default is the whole point. You are not listing what to block, which is an endless job. You are listing the three things allowed and refusing everything else.

Step 3: Docker, the repo, and the secrets#

Docker goes on, the two repositories get cloned to /srv/fiventhree, and then the only genuinely delicate part: production secrets.

The database password, the app password and the session signing secret live in a .env.production file that is not in git and is chmod 600, so only my user can read it. They're generated with openssl rand, never typed and never chosen by a human, because a human-chosen password is the weakest link in an otherwise sound setup.

The backend refuses to start if any of them is missing, too short, or still contains the word changeme:

terminal
$ grep -i changeme .env.production
# no output, so a placeholder can never reach production silently

That guard exists because the realistic failure isn't someone picking a bad password deliberately. It's a placeholder surviving from a template into production, unnoticed, forever.

Step 4: first launch, and the deploy script#

Deployment is one script. It does the boring things I would eventually forget:

- Refuses to run against a dirty working tree. What's live should always correspond to a commit you can point at. - Builds the Blog from markdown, which fails loudly if any article has a bad category reference. - Copies the static sites into place with rsync --delete, so removed pages actually disappear instead of lingering. - Backs up the database before it touches the tracker. - Health-checks the API afterwards and dumps the logs if it doesn't come back. - Aborts if a private document ever reaches a published directory.

That last one deserves a note. My certificates PDF is gated behind a request form rather than published openly. It lives outside the site folder, so it can't be copied by accident. But "can't happen" is exactly the assumption that turns into an incident later. The script asserts it, rather than trusting the path.

Routine updates are now two commands. On the laptop, git push. On the server:

cd /srv/fiventhree && git pull && ./deploy.sh

Static sites are a file copy, with no restart and no downtime. Only the tracker rebuilds containers, and only when its own code changed.

The four things that broke#

1. SSH still accepted passwords after I turned them off#

I set PasswordAuthentication no in /etc/ssh/sshd_config, restarted, and passwords still worked.

The cause is a genuinely counterintuitive rule: sshd uses the first value it finds for any setting, not the last. The main config file has an Include line near the top pulling in everything from sshd_config.d/, and the cloud image had already dropped a file in there saying PasswordAuthentication yes. Because that file was read first, it won, and my setting further down the main file was simply ignored.

Two fixes: correct the offending file, then add a hardening file named so it sorts earlier than anything else that might appear later. Then verify by actually trying, rather than reading the config and believing it:

terminal
$ ssh -o PreferredAuthentications=password n53@fiventhree.com
Permission denied (publickey).

That error is the success case.

2. The deploy script wasn't executable#

terminal
$ ./deploy.sh
-bash: ./deploy.sh: Permission denied

Windows has no concept of a Unix executable bit, so git recorded the file as non-executable and faithfully reproduced that on Linux. The fix is to tell git directly, from the Windows side:

git update-index --chmod=+x deploy.sh

Anyone developing on Windows and deploying to Linux hits this eventually.

3. My own safety check aborted a perfectly good deploy#

terminal
==> Publishing static sites
error: the gated certificates PDF is in the webroot - aborting

The check was scanning all of /srv for that PDF. But the repository itself is cloned inside /srv, and it legitimately contains the private copy. So the check found the file it was supposed to protect and treated it as a leak.

The fix was to scan the three published directories specifically rather than their shared parent. Worth noting because an over-broad safety check is its own kind of bug: it fails safe, which is right, but a check that cries wolf is a check you'll eventually disable.

4. Three sites returned 404 while the fourth worked perfectly#

This was the good one.

terminal
$ curl -sI https://fiventhree.com | head -1
HTTP/2 404
$ curl -sI https://tracker.fiventhree.com | head -1
HTTP/2 200

Certificates were valid. DNS was right. Caddy's logs showed successful TLS handshakes and then 404s. The files were definitely on disk in /srv/homepage.

The asymmetry is the clue. The tracker worked; the three static sites didn't. The tracker is a reverse proxy: Caddy forwards the request to another container and never touches the filesystem. The other three are files on disk, and Caddy has to read them.

Caddy runs in a container. My config said root * /srv/homepage, and that path was being resolved inside the Caddy container, which had no such directory. Caddy was looking for files it could not possibly see, and honestly reporting that it couldn't find them.

The fix is three lines mounting the host directories into the container, read-only, because Caddy serves those files and never writes to them.

Note

One ordering detail that isn't obvious: publish the files before the container's first start. If Docker creates those directories itself they end up owned by root, and the deploy script, which runs as a normal user, can no longer write to them.

Backups, which is the part I actually care about#

Everything above is recoverable. If the server burns down I can rebuild it from the repository in an evening. The database is the exception: it's the only thing that exists nowhere else.

So there are two layers.

On the server, a nightly dump at 03:15, keeping 30 days. It can also verify itself by restoring the dump into a throwaway container and counting the tables, because an untested backup is a guess:

terminal
$ ./scripts/backup.sh --verify
==> dumping lifestyle_tracker
==> wrote /srv/backups/tracker/tracker-20260730-230604.sql.gz (12K)
==> verifying by restoring into a scratch container
==> restored cleanly: 33 tables

Off the server, a scheduled task on my laptop pulls those dumps down each morning over SSH. Backups on the same machine as the database protect you from a bad migration or a mistaken delete. They do not protect you from losing the machine, which is the failure that actually costs you everything.

That script does one thing that matters more than the copying: it exits with an error if the newest dump is more than 48 hours old. The realistic way you lose data is not a dramatic crash. It's the nightly job quietly stopping in October and you finding out in March, having believed you were covered the whole time. That kind of quiet failure is the one that gets you, so this makes it loud.

Verifying it, rather than assuming it#

Once everything was up, the checks that mattered:

CheckExpectedWhy
Database port listening on the hostnothingIt should be reachable only by the backend
Gated certificates PDF over HTTPS404Private documents stay private
Unsigned photo URL403Progress photos need a valid signature
Security headers on every sitepresentHSTS, nosniff, frame-deny, referrer policy
Tracker in a private windowdemo modeVisitors see sample data, never mine

That last one is a design decision worth spelling out. Anyone can open the tracker and use it: add workouts, log meals, click everything. They're using a demo dataset generated in their own browser. The demo never contacts the backend at all, so it isn't "your request was rejected", it's "there was no request". Safe by construction rather than by permission check.

The one thing I deliberately did last#

CAA records restrict which certificate authorities are allowed to issue certificates for a domain. Good hardening, because it means no other CA can be tricked into issuing for fiventhree.com.

I added them after HTTPS was confirmed working, not before. A wrong CAA record blocks certificate issuance outright, and every failed attempt counts against a rate limit. Adding it during the first deploy means debugging it simultaneously with DNS, the firewall, and a first container launch, with no way to tell which one is at fault.

Sequencing is a real technique. Add the thing that can block you only once you've proven the thing it might block.

What I'd tell someone doing this next week#

- DNS first, then the firewall, then the app. Each one is hard to debug through the others. - Verify by trying, not by reading the config. SSH claimed to be hardened and wasn't. - When one thing works and three don't, the difference between them is the answer. The tracker worked because it never touched the filesystem. That single fact located the bug. - Write the check that would have caught it. Every failure here is now a guard in the deploy script or a note in the runbook. - Set up backups before you need them, then test the restore. A backup you have never restored is not a backup, it's a hope.

None of this is exotic infrastructure. It's one server, one web server, four hostnames and a script. But it is mine end to end, and knowing exactly why each piece is there is worth a lot more to me than a deploy button that works without my understanding why.