───

I run a personal server. It’s one machine, rented by the month, in a data center I will probably never visit. On it: a habit tracker, a finance ledger, a diary, a reading list, a file browser for my notes, a travel planner, and a few small tools I wrote because nothing else did the exact thing I wanted.

It started as a hobby, and then somewhere along the way it stopped being one. By any honest definition it’s production infrastructure now β€” the most dangerous thing a hobby can turn into, because production infrastructure is supposed to have backups, monitoring, and more than one person who knows how it works. Mine has me, a shell prompt, and a list of things I’ll fix this weekend.

I think about it in six layers, outside in: access, edge, apps, data, agent, infrastructure. This piece is about the top two. Access = how anything from outside reaches the box. Edge = what’s standing at the other end, deciding whether to let you in. So let’s get it started.

Okay so, first there was nothing Link to heading

The server arrived as a fresh install and a login prompt. No website, no firewall of its own, nothing running. Just SSH β€” remote control for a computer with no monitor.

The first thing I did wasn’t installing software. It was changing how that door opens: keys instead of passwords. A password can be guessed. A key is a very long number that lives on your machine and never gets typed anywhere. Good news: that took ten minutes.

Then I left the login logging on and watched it, and well, that was educational. That one door has recorded more than a hundred thousand failed logins and blocked twenty-one thousand addresses. Nobody is attacking me personally β€” that’s just weather. Automated programs trying common passwords on every address they can find, all day, forever. Which sets the tone: anything you leave open will eventually be found, and it won’t be by someone interesting.

[FILL IN: why you started self-hosting at all β€” the thing that pushed you off hosted apps]

[FILL IN: what specifically frustrated you about the hosted alternatives β€” be concrete if you can]

Layer 1: access, or how anything gets in at all Link to heading

The first thing I built was small: [FILL IN: the first app you built and what it was for].

A port = a numbered slot on a machine. One for the website, one for the database, one for whatever sends the mail. If a program is listening on a port and the network lets traffic through, anyone who knows your address can walk up and say hello.

Think of it this way: the machine is an office building and the port is a room number. Which raises the obvious question β€” what do you do when every new employee gets their own room number, and nobody’s checking who walks down the corridor?

So my app was reachable at http://<server-ip>:<app-port>, which felt like a complete deployment for about a day.

It wasn’t. It was a program listening for anyone who knocked, with no encryption and no lock, reachable only because I had also, without thinking, opened that port in the cloud provider’s firewall. Two things had been loosened: the program agreed to listen to everybody, and the network agreed to let everybody through. Neither was a decision I remember making. They were defaults I accepted, which is a nicer way of saying I didn’t know they were decisions.

Then a second app needed a port, so: another hole. Then a third. Suddenly I had three doors open to the internet, three separate ideas about who was allowed in, and a browser history full of addresses with numbers stuck on the end.

That’s when the front door became necessary β€” not because I’d read something sensible, but because the alternative was an unlimited number of individually secured holes, and I knew one of them would be the one I built at one in the morning and forgot about.

Okay, quiz time. Your second app needs a port. What do you do?

  • A. Open another port and tell yourself you’ll remember which one is which
  • B. Give up and go back to a spreadsheet
  • C. Put one front door in front of everything and stop opening ports

I did A twice before I got to C. That’s the part where I’d like to say I learned quickly.

The chat app is also a door Link to heading

Before any of the web stuff, there was a chat window. I run most of this system by messaging it β€” I send a sentence, a job runs, it answers.

It’s easy to not count that as part of the access layer, because it doesn’t look like a port. But it is one, and the most powerful one I have, because the thing on the other end can run commands on the machine. If somebody got into that chat account, they wouldn’t get a habit tracker. They’d get the computer.

So it gets different treatment: exactly one account can talk to it, the credential never sits in a file that gets committed, and I assume the chat company can read everything, because they can. It works from a phone, on any network, with no warning to click past β€” and it’s the door I control the least.

Tunnels, and the URL that stopped existing Link to heading

Some apps I need from my phone, and I’d rather not open a port for them. The answer is a tunnel. A tunnel = your server makes an outgoing connection to a service on the internet, and traffic comes back down that same line. No new hole in the wall.

There’s a fast way to do this that gives you a working web address in four seconds. The catch is that the address is random, and it changes every time the program restarts. (I read that sentence, and then I bookmarked one anyway. That’s the whole personality type.)

I bookmarked one of those. It stopped working. I bookmarked the next one. It stopped working too.

Yup. Twice. An address that changes isn’t an address, it’s a coincidence that resolves for a while. Anything I wanted to keep needed a name that survived a restart. That’s a very boring sentence, and it cost me an evening.

[FILL IN: what you actually use the phone access for at eleven at night β€” the thing that made the friction matter]

The private road nobody drives on Link to heading

The last path is a private network. A small program on the server and on my devices puts them all on one virtual network, with addresses that only exist between them. Nothing exposed to the internet. Right place for anything that doesn’t need to be public: remote login, admin screens, databases. Honestly, if I were starting again, this is the path everything would use and the public door would be the exception.

And here’s the part I’d rather not write down. On this server, that private network is currently logged out. The service is running, the health check is green, the road is there β€” and nothing can drive on it. It’s been like that long enough that I stopped noticing.

The principle β€” A path nobody uses is a path nobody tests, and a path nobody tests is broken without telling you. The public front door fails loudly: you get an error page and go look. The private road fails silently, because the only one who’d notice is the person not using it. If a piece of your setup has no user and no monitoring, assume it’s broken and go check. Mine was.

What I’d do differently on access Link to heading

  1. Write down every path and what it’s for, before adding the next one. Path, how it travels, what checks your identity, whether it works. That table found two things broken and one thing pointless.
  2. No temporary addresses for anything you plan to keep. A stable name or nothing. Temporary addresses teach you to treat links as disposable, and then you paste them into places that keep them forever.
  3. Make private the default and public the exception. I did it backwards, and backwards is harder to undo, because by then the public addresses are in your bookmarks and on your phone’s home screen.
  4. Decide how people prove who they are at door one, not door eight. It sounds like an edge decision. It isn’t.

The principle underneath all four is default-deny: nothing gets in unless something explicitly says it may. What you get by accident is the opposite, and the opposite is what I had. It costs ten minutes at the start and saves a whole class of incident you’d never see coming.

So, how many doors does your setup have, and could you name all of them without looking?

Layer 2: the edge, or one door with a list Link to heading

The edge of this server is a program called nginx. It’s a web server that mostly doesn’t serve anything β€” it stands at ports 80 and 443 and decides where each request should go.

A reverse proxy = one front door for the whole building, with a list of which room you’re allowed into. You knock once, at one address. The door decides that /ledger/ is the finance app, /habit/ is the habit tracker, /board/ is the list of things I keep meaning to do. Each app sits on a private slot inside the machine that the outside world can’t reach.

Here’s what the door actually buys you. One place to put a password. One place to decide who’s a stranger. One list that tells you every room that exists, which sounds boring and is the single most useful thing about it. Ten doors with ten locks means ten chances to leave one unlocked and no way to see it from the street.

Each route in that list is short, and they all look the same: a password check, then a line pointing at the private address where the actual app is listening. 127.0.0.1 is how a computer talks to itself, so nothing outside can reach that slot directly.

Three of those lines caused most of my pain, and none of them was the interesting part of the job.

  • A trailing slash on the forwarding line. One character decides whether the web address the app receives still has its own name on the front of it. Get it wrong and the page itself loads while every image, every stylesheet, and every button asks for the wrong place β€” because the app has no idea it was installed under a name.
  • One header that says “this person arrived encrypted.” Leave it out and apps generate insecure links, bounce you into a redirect loop, or quietly drop the secure flag on your cookies.
  • A timeout. The default is sixty seconds, so an app that honestly needs two minutes looks like it’s hanging, when what actually happened is the front door gave up and walked away.

The principle β€” This is the choke point principle: put one thing in the path that everything must pass through, then you only have to get that one thing right. It’s the best structural decision in this build. It comes with a bill, which I’ll get to. But the direction is right, and I’d defend it in front of anyone.

The certificate nobody trusts Link to heading

A certificate = a file that says “this really is that server,” signed by somebody your browser already trusts. The everyday version is a passport: it doesn’t tell you the person is trustworthy, only that the government checked who they are. Mine is signed by nobody. The subject line literally reads CN = <the IP address> and the issuer is the same address β€” it says the server is an IP address, because that’s what it is. I made it myself in four seconds and then didn’t think about it again for months. It expires, eventually, which is either a deadline or a surprise.

So every browser shows a full-page warning β€” your connection is not private β€” with an advanced button and a proceed-anyway link. Every new device. Every single time.

I told myself I knew why. Nobody will issue a certificate for a bare IP address β€” that was true, that was the rule β€” so you need a domain: buying one, pointing it at the box, admitting this thing deserves a name. A self-signed certificate is what you get while you put off all three.

Except the reason expired and nobody told me. In January 2026 the free certificate authority started issuing certificates for IP addresses β€” short-lived ones, 160 hours, a bit over six days, but real, and signed by an authority browsers already trust. I found that out while writing this post. The reason I’d been carrying around had been false for months, and I never went back to check it, because a reason you’ve already accepted doesn’t feel like a decision you still owe anyone.

[FILL IN: what you were actually trying to do the first time you clicked through that warning β€” and how it felt on a device that wasn’t yours]

The principle β€” A self-signed certificate gives you encryption. What it doesn’t give you is identity β€” nothing in it proves the server is yours, so somebody in the middle of the connection is undetectable. The real damage is behavioural: it trains you to click past the one warning that exists to stop exactly that, and that habit is how people lose real money. Worst trade in this build, and the excuse for it had been expired for months.

A password popup instead of a login system Link to heading

Basic auth = the little browser popup asking for a username and password, which is about as secure as a screen door with a very good lock. nginx checks it before the request ever reaches the app. One username, one password, hashed into a file.

The alternatives, and why I didn’t take them:

  • A real identity system. The good open-source ones handle logins, sessions, roles, and revocation, and they’re correct for anything with more than one user. They’re also a database, a session store, and a login page I’d have to keep patched, to protect a habit tracker from a threat model that amounts to “somebody finds the address.”
  • Letting each app handle its own login. Several of mine have one, several have none, so my security would be only as good as the least careful app β€” and I wrote them all.
  • Nothing, on the grounds that nobody knows the address. An address in your browser history, your bookmark sync, and one screenshot is not a secret. Obscurity isn’t a control, it’s a delay.

Basic auth wins on one property: you cannot forget to apply it. Four lines, and if a route doesn’t have them, the change is visible in the file.

What it costs: one password for everything, no logout because the browser caches it, no expiry, and no way to cut off one app without changing it everywhere. The login dialog still announces a project name I stopped using a year ago, which I’ve left as a small monument to my past.

And coverage is the number I’d never actually counted until I sat down and did it. About two dozen routes at the front door; most of them ask for a password, and a handful don’t. Of those, some are redirects, some are the agent’s own control interface with its own token, one is an app with its own login page. So there are two security models running side by side here, and I’d been thinking of it as one. That’s the sort of thing you only find by counting.

Two bouncers, and I could only see one of them Link to heading

A firewall = the bouncer standing at the door with a list. On a rented server there are two of them, and this is the piece it took me an embarrassing amount of time to understand.

The first one lives in the cloud console β€” a web page. You open a port there or nothing arrives. I knew about that one, because you can’t create the server without meeting it.

The second one lives on the machine itself, and for a long time I would have told you it wasn’t there. I typed ufw, the standard tool, and got command not found. So: not installed. Except it was installed, enabled, and had been quietly dropping packets since July. It just doesn’t sit on the path an ordinary shell looks at β€” you need the full path, or root.

Here is the policy I eventually read properly, in plain terms: everything coming in is denied, and exactly two things are allowed β€” remote login and the web. That’s the correct policy, and it was correct the whole time.

Except for one extra line. Somewhere in that list is an app port, opened once at the very beginning, back when an app was reachable on its own number, and never closed. The front door arrived; the rule stayed β€” because a rule that’s still doing something is much harder to notice than a rule that breaks something.

The principle β€” The comfortable story was “the cloud firewall handles it, the machine doesn’t need one.” That story is how you end up with one enforcement point you never look at and no idea that a second one exists. Two layers of anything only help if you can see both; otherwise it’s one working layer and one mystery. The habit worth building: whenever you assume something is handled somewhere else, go and read the somewhere else.

The part that is real, local, and visible is the jail. fail2ban = a small program that watches the login log and blocks addresses that keep failing.

Three failures in ten minutes, blocked for an hour. It has blocked tens of thousands of addresses. I’d change only the hour β€” a persistent scanner just comes back.

The failure I went looking for, and the one that was actually there Link to heading

The cost showed up anyway, just not where I expected. I went looking for the service somebody started by hand and forgot about β€” every single listener on that machine turned out to be a real service, started at boot. That part works.

The inconsistency is next door instead: two older versions of the same app still have their own routes at the front door, because I built a new one and never turned the old ones off. Nothing is broken. There are just three addresses that all claim to be the same thing, and only one of them is the one I actually use.

The parts I’d rather not put in writing Link to heading

Almost every app here was originally started so that it listened on all network interfaces, not just the machine’s own internal one. That’s the tutorial default, and it’s what you get when you copy the previous app’s first line, which is what I did. (I went and checked every one of them while writing this, which is how you end up with a list instead of a feeling.)

I fixed it in two places, after noticing that one of them served a page listing every file on the machine β€” including the ones with numbers on them. If a firewall rule ever changed, that page would be reachable directly, with no password, because the password lives at the front door and the front door wouldn’t be in the path. The fix is one word on one line. It took months, because nothing was broken β€” and nothing was broken because nothing had changed. The rest are on a list I haven’t got to yet, and a list is not a fix.

Then there’s the route that outlived its app. I deleted an app β€” stopped it, disabled it, removed its card β€” and didn’t remove its entry in the front door’s list, because nothing was broken. Months later I built something new, it needed a port, and it took the port the dead route was still pointing at. A route is a permanent public name, ports get recycled, and those two have separate lives. I was managing one.

There’s a version of me from a few months ago who would have answered “it’s fine, nobody knows the address.” That isn’t a security position, it’s a hope with a password attached.

Last one, and it’s my favourite. I built a page for dropping large files onto the server. It worked in testing. The first real upload failed with 413 Request Entity Too Large β€” from the front door, not the app. nginx has a default limit of one megabyte; the app’s own limit was two gigabytes. Two ceilings, two places, and I’d thought about one. I raised it, and then it failed again for a better reason: the form posted to the root of the site instead of the path with the raised limit. Two mistakes, one afternoon. I’d been thinking of the app as the whole system, when the system includes everything standing in front of it.

[FILL IN: the night something broke β€” what happened, what you saw, how you found it]

What I’d do differently on the edge Link to heading

  1. Bind everything to the internal address from the first commit. It makes the front door the only way in, so its authentication is the only authentication.
  2. Buy the domain before the second app. Real certificates, stable names, and never typing a number into a phone again.
  3. Write the protected route once and reuse it. One snippet, included everywhere β€” then “is this protected” is answered by whether the include is there.
  4. Delete the route in the same commit that deletes the app. Entry, service, card, port. Four things, not three.
  5. Make the firewall readable from inside the machine. A console I visit when I remember is not a monitoring strategy.

The next step Link to heading

The next version is one decision and a weekend of work: buy a domain, and move everything behind it. A domain = the name, so the front door stops being a number.

Strictly speaking it’s no longer required β€” an IP certificate would kill the warning page tomorrow, and that’s the honest reason this is a choice and not a requirement. I’d make the choice anyway. Names beat numbers, and a long-lived certificate beats one that renews every six days and needs its own small machine to stay alive. Every app also gets a stable address instead of a port someone has to remember, and the firewall can stop allowing anything except the front door. The stray port rule I found in that list comes out the same day.

Why hasn’t it happened? Money. A domain is cheap in absolute terms β€” roughly the price of lunch, once a year β€” but the AI subscriptions behind this server already cost real money every month, and “this would be more secure” has been quietly losing to “I’ve already spent a lot on this” for months. The security work is invisible when it succeeds, so it’s the first thing deferred and the last thing funded. I know that’s the wrong trade. Writing it down is my way of moving it from vague to embarrassing.

Next: the apps themselves β€” what they’re built out of, why the boring choice kept winning, and what happens when one of them dies quietly at two in the morning.

Every mistake in this post is Xuan’s. Every sentence is Gideon’s. Gideon also walked into every room described above to check whether the lights worked, which is more diligence than this server has ever received from its owner.