What I Learned Securing My First Public-Facing Server
A few months ago I spun up a self-hosted git forge (Gitea) on a small VPS. I wanted somewhere to keep personal projects without relying on a third party. What I didn’t fully appreciate going in was how quickly the internet notices a new service. Within hours of the box going live, my logs already showed thousands of failed login attempts from addresses scattered across the globe. These bots methodically probe for open ports, default credentials, and known vulnerabilities.
None of that is unique to Gitea. Any public-facing service, no matter how small or obscure you think it is, gets found and probed almost immediately. This post walks through the practices I put in place to handle that: infrastructure-as-code, the reverse proxy, firewalls, fending off brute-force attempts, and just keeping the thing running and up to date.
Foundations: Infrastructure as Code
Before touching any application config, I set up the server itself using Ansible. I avoid doing manual, one-off configuration changes over SSH. Everything is an Ansible playbook or role tracked within a git repo. I get these benefits when everything goes through a playbook in version control:
- The playbook is the source of truth. If I want to know exactly what’s configured on the server (packages, firewall rules, or services), I don’t need to SSH in and look. All that info is within my Ansible repository.
- It’s auditable. A diff in a playbook is a much clearer record of “what changed and when” than my own memory of a late-night SSH session.
- It’s up-to-date documentation, for free. Six months from now I won’t remember the details of my SSH hardening config. The playbook remembers for me.
- It’s disaster recovery. If the VPS disappears tomorrow, provider issue, hardware failure, whatever, I can stand up a replacement and re-run the playbook rather than trying to reconstruct my setup from memory.
- It’s testable. I can spin up a throwaway VM or container, apply the playbook, and verify a change works before it ever touches the production server.
Configuration drift, where the running system slowly diverges from what you think is configured, is a quiet, cumulative risk. Playbooks are the cheapest insurance against it.
First Steps on a New Host
Every server I stand up gets the same initial treatment, in the same order:
- Update and upgrade packages. Start from a patched baseline, not whatever the image shipped with.
- Create an admin user. Root doesn’t SSH in directly, ever. Best practice is to use a separate account with sudo instead.
- Add my jumpbox’s public key as an authorized key (
ssh-copy-id), so I can get in with the new user. - Harden SSH: disable root login, disable password authentication, and restrict login to the admin user only.
- (Optional): move SSH off port 22 entirely. This doesn’t stop a targeted attacker who’s actually looking for you, but it cuts out a huge amount of automated noise. Most scanning bots only bother probing the handful of default ports, so simply moving off them makes a real, measurable dent in your logs. Using non-default ports is one more layer of obfuscation.
- (Or, optionally): put SSH access behind a VPN, so the SSH port isn’t reachable by the open internet at all, only by devices already on that private network.
Why not just SSH in as root? Two separate reasons stack here. First, root is by far the most-attacked username on the internet: every automated credential-stuffing bot tries it first, so disabling root SSH login outright removes an entire class of guessing attempts before they can even start. Second, splitting “login” from “privilege” gives you a real audit trail. Commands run through sudo are logged against the specific user who ran them, whereas a shell logged in directly as root leaves no record of who did what. If a key is ever compromised, sudo logging is often the difference between knowing exactly what an attacker touched and just knowing something happened.
Why is a VPN safer than exposing SSH directly? It’s not that SSH itself is weak; key-based auth with root and passwords disabled is already solid. It’s about reducing surface area. An SSH daemon exposed to the whole internet is still reachable by every scanner and bot out there, and any future SSH vulnerability (there have been a few over the years) means every random IP on earth gets a shot at it the moment it’s disclosed. Put it behind a VPN or Tailscale instead, and the SSH port is simply never visible to the public internet at all. An attacker would need to compromise the VPN itself first, which is a much smaller, much better-audited attack surface than “anyone who finds the IP.”
WARNING
Verify key-based login actually works before you disable password authentication. Test the key login in a second terminal session before you close the one that still allows passwords. It’s a very easy way to lock yourself out of a fresh box if you get the order wrong.
Because this is all in the playbook, it’s identical every time: new server, same hardened baseline, no manual steps to forget.
Architecture
With the host itself set up, I install the actual application. This section isn’t Gitea-specific; the same shape applies to pretty much any self-hosted service sitting behind a reverse proxy with a database backing it. A few decisions here shape everything downstream:
Containers, and rootless where practical. I run services in Docker containers rather than installing them directly on the host. It keeps dependencies isolated, makes upgrades a matter of pulling a new image tag, and gives me a clean way to roll back if something breaks. Where it’s not too much extra friction, I run rootless containers, which meaningfully shrinks the blast radius if a container is ever compromised. It’s not a complete containment story on its own; it’s one more layer, not a substitute for the firewalling and access control described below.
Internal networking, not open networking. Caddy is the only thing that talks to the internet, and it’s the only thing that can reach the backend application. The backend sits on an internal Docker network reachable only by Caddy. The database sits on its own network reachable only by the backend application, and its port is never published to the host at all. So the chain looks like: internet → Caddy → app → database, with nothing able to skip a link. If you can’t reach a port from outside the container network, it can’t be attacked from outside the container network. This is a small amount of Docker Compose configuration that buys a lot of exposure reduction.
Segmenting at the network level, if you’re not on a VPS. If this is running at home or in a homelab rather than on a VPS, put internet-facing services on their own VLAN, isolated from your personal devices, NAS, and everything else on your network. Firewall rules should prevent that VLAN from initiating contact with the rest of your network, while still allowing your jumpbox or Ansible controller to reach in and manage it. The goal is the same as the internal Docker network above, one level up: if the exposed service is ever compromised, it shouldn’t be a stepping stone to everything else you own.
The Reverse Proxy: Your Only Public Door
Worth calling out on its own, since it’s the single most important piece of the architecture above: the reverse proxy is the only thing that should ever be reachable from the public internet. The application and its database live entirely on internal networks; they never listen on a public interface at all, so even a misconfiguration elsewhere can’t accidentally expose them directly.
I’ve used NGINX for years in my homelab, but for public-facing services like this one I’ve switched to Caddy. The deciding factor wasn’t features; NGINX can do essentially everything Caddy can. It was config surface area. NGINX’s config syntax gives you a lot of rope: location blocks, header handling, and proxy_pass directives that are easy to get subtly wrong in ways that quietly leak a header, forward a path you didn’t intend, or fail open instead of closed. Caddy’s Caddyfile is much more opinionated and declarative, with sane, secure defaults baked in, including fully automatic HTTPS via Let’s Encrypt with no separate certbot setup to maintain. Fewer knobs means fewer ways to misconfigure something without noticing.
Authentication at the proxy layer. Beyond routing and TLS, the reverse proxy is also a natural place to put authentication, in front of the application, rather than relying solely on the app’s own login. I’ve been experimenting with Authentik for this, using the “forward auth” pattern: the proxy checks with Authentik before forwarding a request to the backend at all, and only lets it through once there’s a valid, authenticated session.
The reason this is more secure than app-level auth alone isn’t that an app’s own login is untrustworthy; it’s about where the attack surface sits. If the app is the only thing checking credentials, then the app’s login endpoint is itself directly exposed to every bot and scanner on the internet, and any bug in that specific implementation is fair game. Push authentication up to the proxy instead, and unauthenticated requests never reach the application’s code at all; the app only ever sees traffic that’s already been vetted. It also centralizes policy: with multiple self-hosted services behind the same proxy, you get one login and one place to manage sessions, MFA, and access policy, instead of each app re-implementing its own auth with its own potential weaknesses. I haven’t fully rolled this out yet, but it’s next on the list, more on that at the end.
Firewalls
Host Firewall
I run UFW on the host: deny all incoming traffic by default, then explicitly allow only what’s necessary, the web proxy’s ports, SSH (or your VPN’s port, if you went that route), and nothing else.
Here’s where I ran into my first real gotcha. After configuring UFW, I found I could still reach ports that should have been blocked. The cause: Docker manipulates iptables directly, inserting its own rules into the DOCKER-USER and DOCKER chains, and those rules get evaluated before UFW’s own chain in the packet path. Practically, this means a docker run -p 5432:5432 can expose a port to the world regardless of what your UFW rules say.
The fix I used is the ufw-docker project, which inserts the necessary rules into after.rules so UFW and Docker’s networking actually cooperate. I added this as a step in my playbook so it’s applied automatically on every new host, rather than something I have to remember to do by hand.
Network Firewall
If you’re on a VPS, your provider is very likely already running a network-level firewall (a security group, in cloud terms) in front of your instance. Worth checking what’s already there before assuming your host firewall is your only line of defense.
If you’re self-hosting on your own network, this is where the VLAN segmentation mentioned above comes in: firewall rules at the network level, not just the host level, keeping the exposed service walled off from everything else.
Verify It Yourself
It’s not enough to write firewall rules and trust that they do what you think they do. After setting mine up, I ran an nmap scan from an external host, not from the server itself, since that won’t tell you what the outside world actually sees, to confirm that only the ports I intended to expose were actually reachable:
nmap -Pn -sV your.server.ip-Pn skips the initial host-discovery ping (some hosts/firewalls drop ICMP, which can otherwise make nmap think the host is down before it even scans), and -sV attempts to identify the service and version running on each open port. Ideally, this comes back showing exactly the ports you meant to expose, 80/443 for Caddy, maybe your VPN or SSH port, and nothing else.
A footgun: when the scan lies to you
The first time I did this, the results were strange. The scan took far longer than it should have, and it came back reporting that most ports were open, each with a plausible-looking service and version identified on it. That’s obviously not what I’d configured; UFW was set to deny by default with only a couple of rules allowed through.
The likely explanation, best I can tell, is that some VPS providers run network-level protection, likely an Intrusion Prevention System (IPS), that actively messes with port scans as a defensive measure. Rather than simply dropping or rejecting unsolicited probes, some of these systems respond to scans with decoy or “tarpit” behavior: answering on ports that aren’t really open, sometimes with fabricated service banners, specifically to make reconnaissance against the host slower and less trustworthy for an attacker. This is a real, named category of technique. The Linux TARPIT target for iptables will hold open a connection on every port and simply never let it go anywhere, and tools like Portspoof go further, emulating a different fake service on every port with a delivery pattern deliberately designed to make a full nmap -sV scan take hours and burn through a scanner’s threads for nothing. I can’t say for certain that’s exactly what I ran into, but the symptoms lined up closely: a scan that dragged on far longer than expected, and a wall of “open” ports each confidently reporting a service that almost certainly wasn’t really there.
TIP
- Check what’s actually listening, on the host itself.
ss -tulpn(ornetstat -tulpn) shows you the real picture from inside the box: what’s bound to what port, and by which process. If this shows only what you expect, and the discrepancy is only visible from an external scan, that points toward something in the network path, not your host firewall. - Check your provider’s dashboard and docs. Look for anything described as a firewall, IDS/IPS, DDoS protection, or “port scan protection” at the account or network level. This is exactly the kind of feature that would produce what I saw, and it’s often mentioned (if briefly) in the provider’s networking documentation.
- When in doubt, ask the provider. A quick support ticket describing exactly what you saw is usually enough to get a straight answer on whether this is expected behavior on their end.
In my case, this turned out to be exactly that: provider-side scan protection, not a problem with my own configuration. But it’s a good reminder that “verify it yourself” comes with its own caveat: the tool doing the verifying can be lied to as well, and it’s worth knowing that before you trust its output at face value.
Defending Against Intrusion Attempts
As mentioned up top, within hours of the site going live, I had thousands of failed login attempts in the logs. This is just background radiation on the internet: bots continuously scanning the address space, looking for open ports and login pages to throw common credentials at.
fail2ban is the standard tool for pushing back on this. It watches your application logs for patterns, failed login attempts most commonly, and bans the source IP for a configured period once it crosses a threshold.
Setting up jails
Create a dedicated jail for each exposed service (SSH, Gitea’s login endpoint, anything else with a login). A reasonable starting point looks like this:
[DEFAULT]
# Don't ever ban addresses on this list
ignoreip = 127.0.0.1/8 ::1 YOUR.HOME.IP.HERE YOUR.VPN.SUBNET.HERE
# Ban for 1 hour on a first offense
bantime = 1h
# ...within a 10 minute window
findtime = 10m
# ...after 5 failed attempts
maxretry = 5
[sshd]
enabled = true
[gitea]
enabled = true
filter = gitea
logpath = /path/to/gitea/log/gitea.log
maxretry = 5WARNING
Whitelist your own IP with ignoreip. It’s entirely possible to get banned by your own fail2ban configuration while testing it, or just from fat-fingering your own password a few too many times. Add your home IP, your VPN subnet, or wherever you access the server from, so a bad afternoon of typos doesn’t lock you out of your own service.
One more note on those defaults: don’t ban forever. It’s tempting to reach for very long or permanent bans, but I’d avoid it for two reasons. First, IP addresses churn: most residential and mobile connections get reassigned addresses periodically, so a long ban can end up blocking some innocent future resident of an address a bot once used, rather than the original attacker. Second, and more simply, the “attacker” today could be a legitimate user tomorrow if that address is reassigned, and you don’t want to be silently blocking real traffic indefinitely with no way for anyone to know why. A shorter ban that ratchets up for repeat offenders (below) gets you most of the protective value without the long-term collateral damage.
The recidive jail
fail2ban includes a built-in recidive jail specifically for repeat offenders. It watches fail2ban’s own log for IPs that have been banned multiple times already, and bans them again for much longer:
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
bantime = 1w
findtime = 1d
maxretry = 3This gives you a sensible escalation: a first offense gets a short, low-cost ban (maybe a few hours), and an address that keeps coming back gets banned for a week or longer. It’s a reasonable middle ground between “ban everything forever” and “ban nothing for long enough to matter.”
Two more gotchas
Established connections don’t get killed by a ban. The first time I tested my setup, I noticed that even after an IP was banned, an already-open connection to the login page kept working. I could keep submitting login attempts on that same connection. This makes sense once you think about how the ban is enforced: fail2ban’s default UFW action blocks new connections; it doesn’t touch a connection that’s already established, so an in-progress keep-alive connection just keeps working until the client closes it.
The fix, since I’m using UFW as the ban backend, is UFW’s kill-mode option: off by default, but settable to either ss or conntrack to immediately drop existing connections from an IP the moment it’s banned, rather than just blocking new ones:
[gitea]
enabled = true
filter = gitea
logpath = /path/to/gitea/log/gitea.log
maxretry = 5
action = ufw[kill-mode=conntrack]With kill-mode=conntrack, fail2ban runs conntrack -D -s <ip> alongside the ban, tearing down the connection tracking entries for that address and killing any live connections outright.
Docker strikes again. For the same underlying reason as the UFW/Docker issue above, fail2ban’s stock ufw action doesn’t reach traffic destined for a published Docker port either; Docker’s own DOCKER-USER chain sits ahead of it in the packet path. The fix was a small custom fail2ban action, based on the stock ufw action, that additionally inserts and removes a DOCKER-USER rule for the banned IP, with the same kill-mode support carried over. I saved mine as /etc/fail2ban/action.d/ufw-docker.conf and reference it from a jail the same way as the stock action: action = ufw-docker[kill-mode=conntrack]. The full action file is up here if you want to adapt it directly, rather than pasting the whole thing inline.
Don’t Forget Your Application’s Own Settings
Everything so far has been about the network and the infrastructure around the app. It’s worth setting aside time to go through the application’s own configuration too, since most self-hosted software ships with security-relevant settings that aren’t turned on by default. In Gitea’s case, a few I’ve set:
- CAPTCHA on the login page, to add friction against scripted login attempts.
- Forced 2FA, so a leaked or guessed password alone isn’t enough to get in.
- Registration disabled, since this is a personal instance and there’s no reason to let the public create accounts.
REQUIRE_SIGNIN_VIEWset toexpensive, which blocks anonymous access to the pages that cost the most server resources to render, without requiring a login for the whole site.
None of these are exotic. They’re the kind of settings that exist precisely because a piece of self-hosted software can’t guess how public or private you want it to be, so it leaves the decision to you. The specific options will differ by application, but the instruction generalizes well: read through your app’s configuration reference at least once, end to end, and don’t assume the defaults are the secure choice.
Staying Maintained: Updates, Backups, and Watching for Trouble
Getting a service configured securely on day one is only half the job; the other half is keeping it that way.
Updates. I keep OS-level security patches on unattended-upgrades (or the equivalent for your distro), so the base system stays patched without manual intervention. Container images I pin to specific versions rather than tracking latest, so an update is a deliberate decision I make and test, not something that silently changes. I have a regular cadence for reviewing and applying these updates rather than doing it ad hoc, since ad hoc is how things get missed.
Backups. I have a cron job that runs a playbook that makes an automated backup of the application data. Those backups get copied offsite, not just kept on the same host they’re backing up (a host-level failure would otherwise take out your backup along with everything else).
IMPORTANT
The backup gets tested by actually restoring it, periodically, not just trusted to exist. An untested backup is a hypothesis, not a backup.
Watching release channels and CVEs. I subscribe to release notes and security advisories for the software I’m running directly: Gitea, Caddy, and my base OS’s security announce list. It’s worth explicitly separating routine patching from urgent patching. A scheduled update can wait for your normal cadence, but a CVE with an active exploit in the wild for something internet-facing should get patched immediately, out of band, not wait for whatever day you’d normally get to it.
WARNING
Don’t hardcode tokens, passwords, or API keys in playbooks or docker-compose.yml files. Keep them in a .env file with restricted permissions (or a proper secrets manager, if you want to go further), and make sure they’re excluded from version control.
Observability
Finally, I run Uptime Kuma for status and alerting. It periodically hits a health-check endpoint and tells me whether the service is up. Make sure the alerting is actually wired to something you’ll see, a push notification or an email, not just a dashboard nobody’s looking at.
It’s worth being clear about what this does and doesn’t give you: Uptime Kuma tells you whether something is wrong, not why. For that, you’d want proper resource and log monitoring, something like a Grafana dashboard fed by system and application metrics, so that when something does go wrong, you have enough information to actually investigate it rather than just knowing it happened.
Where to Go Next
A couple of things I’ve looked into but haven’t fully put into production yet, worth naming so this doesn’t read as a finished, closed list.
Identity and Access Management
Real proxy-level authentication in front of Gitea, using Authentik. Something I’ve tested and plan to put in front of my public services, per the reverse proxy section above. Right now Gitea still handles its own login; moving that check up to the proxy is the next concrete step.
A Monitoring/Observability Stack
Going beyond Uptime Kuma’s up-or-down check into actual resource and application metrics: CPU, memory, disk, request rates, error rates, all queryable and graphable over time, most likely with Prometheus and Grafana. This is the piece that would let me answer “why” instead of just “whether” when something does go wrong, and it’s also what would catch the slow, boring failure modes, like a disk quietly filling up over weeks, well before they turn into an outage.
Blocking Crawlers
Gitea can generate a huge number of distinct URLs: every file, at every past commit, plus every diff between any two commits. A crawler that blindly follows every link on the site can end up requesting an enormous, ever-growing set of pages, and each one costs the server real CPU to render. Large-scale AI crawlers have been doing exactly this to git forges, sometimes aggressively enough to knock an instance over. REQUIRE_SIGNIN_VIEW: expensive (mentioned above) blocks anonymous access to the priciest pages, but a hard rate limit at the proxy is the more direct fix: cap how many requests any single visitor can make per second, full stop, regardless of which pages they’re hitting.
Anubis takes a different angle on the same problem: it sits in front of the app and makes every visitor solve a small computational puzzle before they’re let through. That’s nearly instant for a real browser, but expensive to do at the scale a crawler operates at. It’s worth mentioning by name because this isn’t a hypothetical problem: Anubis exists specifically because its creator’s own git server was being hammered by an AI crawler, and it’s since been adopted by several other git forges dealing with the same issue.
None of these is strictly necessary to run a reasonably secure service; everything earlier in this post gets you most of the way there. But they’re the natural next layer, worth mentioning so the post doesn’t imply the work is ever really “done.”
Closing Thoughts
None of these practices are individually sufficient on their own; that’s sort of the point. A firewall doesn’t help if your SSH config is weak. Fail2ban doesn’t help if Docker is quietly routing around it. Backups don’t help if you’ve never tested a restore. It’s the combination, infrastructure as code, a minimal public surface behind a reverse proxy, network isolation, layered firewalls, intrusion response, and ongoing maintenance, that adds up to something reasonably resilient.
If you’re setting up your first public-facing self-hosted service and want to know where to start, my honest advice: get your playbook and firewall rules in place first, get backups working and tested second, and treat everything else as valuable but secondary. Those two get you most of the way to “recoverable no matter what happens,” which is the property that actually matters most.